@snowyroad/braid 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  SourceBroker,
16
16
  WS_MAX_PAYLOAD_BYTES,
17
17
  acquireAgentLock,
18
+ applyWin32Acl,
18
19
  assembleHumanFacts,
19
20
  assembleRosterFacts,
20
21
  assertKeystorePerms,
@@ -59,6 +60,7 @@ import {
59
60
  parseCardReply,
60
61
  parseRunFlags,
61
62
  pinEndpoint,
63
+ productionWin32Deps,
62
64
  readPathWithFallback,
63
65
  redeemInvite,
64
66
  relayHostSegment,
@@ -66,6 +68,7 @@ import {
66
68
  resolveAgentSelection,
67
69
  resolveClaudeBinary,
68
70
  resolveConfig,
71
+ resolveCurrentUser,
69
72
  resolveIpcProfile,
70
73
  resolveKnobs,
71
74
  resolveScope,
@@ -85,7 +88,7 @@ import {
85
88
  validateRelayWsUrl,
86
89
  withAgentGitEnv,
87
90
  wsToHttp
88
- } from "./chunk-2OHYN3KD.js";
91
+ } from "./chunk-3SHZYLWH.js";
89
92
  import {
90
93
  clampUntrusted,
91
94
  externalSourceLabel,
@@ -413,6 +416,10 @@ var ChannelSession = class {
413
416
  setHumans(entries) {
414
417
  this.humans = entries;
415
418
  }
419
+ /** D15 (git gateway): called with true before a lisa fresh turn and false after it
420
+ * (in a finally, so a failed turn still re-enables). The bridge wires it to the
421
+ * channel broker's setGitEnabled so critics and builder rounds cannot use git. */
422
+ onLisaFreshTurn;
416
423
  /**
417
424
  * Handoff hold (persist-and-catch-up): non-null while the owner is driving the real
418
425
  * interactive session. Live messages land HERE instead of the (intentionally stopped)
@@ -592,6 +599,14 @@ ${fence("reactions", formatReactions(msg.reactions))}
592
599
  * session or fabricates freshness/usage.
593
600
  */
594
601
  async runLisaFreshTurn(signal) {
602
+ this.onLisaFreshTurn?.(true);
603
+ try {
604
+ await this.runLisaFreshTurnInner(signal);
605
+ } finally {
606
+ this.onLisaFreshTurn?.(false);
607
+ }
608
+ }
609
+ async runLisaFreshTurnInner(signal) {
595
610
  if (!this.flow) {
596
611
  throw new Error(
597
612
  "[braid] lisa fresh-session turn requested but flow routing is unavailable for this session"
@@ -906,11 +921,11 @@ async function elicitCard(converse, agentName, opts = {}) {
906
921
  return buildPartialCard(agentName, { description: opts.fallbackDescription ?? "", skills: [] });
907
922
  }
908
923
  function withTimeout(p, ms) {
909
- return new Promise((resolve, reject) => {
924
+ return new Promise((resolve2, reject) => {
910
925
  const t = setTimeout(() => reject(new Error("elicit timeout")), ms);
911
926
  p.then((v) => {
912
927
  clearTimeout(t);
913
- resolve(v);
928
+ resolve2(v);
914
929
  }, (e) => {
915
930
  clearTimeout(t);
916
931
  reject(e);
@@ -1586,7 +1601,7 @@ function startControlSocket(opts) {
1586
1601
  handleConnection(conn, opts, token, log2);
1587
1602
  });
1588
1603
  server.on("error", (err) => log2(`[braid] control socket server error: ${err.message}`));
1589
- return new Promise((resolve, reject) => {
1604
+ return new Promise((resolve2, reject) => {
1590
1605
  server.once("error", reject);
1591
1606
  server.listen(opts.sockPath, () => {
1592
1607
  server.removeListener("error", reject);
@@ -1597,7 +1612,7 @@ function startControlSocket(opts) {
1597
1612
  }
1598
1613
  }
1599
1614
  log2(`[braid] control socket listening at ${opts.sockPath}`);
1600
- resolve({
1615
+ resolve2({
1601
1616
  path: opts.sockPath,
1602
1617
  close: () => new Promise((res) => {
1603
1618
  for (const c of conns) {
@@ -1628,7 +1643,7 @@ function startControlSocket(opts) {
1628
1643
  });
1629
1644
  }
1630
1645
  function requestStatus(sockPath, timeoutMs = 3e3, token) {
1631
- return new Promise((resolve, reject) => {
1646
+ return new Promise((resolve2, reject) => {
1632
1647
  const c = net.createConnection(sockPath);
1633
1648
  let buf = "";
1634
1649
  let settled = false;
@@ -1662,7 +1677,7 @@ function requestStatus(sockPath, timeoutMs = 3e3, token) {
1662
1677
  finish(() => reject(new Error(`control socket status error: ${String(obj.error)}`)));
1663
1678
  return;
1664
1679
  }
1665
- finish(() => resolve(obj));
1680
+ finish(() => resolve2(obj));
1666
1681
  });
1667
1682
  c.on("error", (e) => finish(() => reject(e)));
1668
1683
  c.on("close", () => finish(() => reject(new Error("control socket closed before a status line"))));
@@ -1680,7 +1695,7 @@ function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
1680
1695
  if (platform !== "win32") {
1681
1696
  return Promise.resolve(existsSync2(sockPath));
1682
1697
  }
1683
- return new Promise((resolve) => {
1698
+ return new Promise((resolve2) => {
1684
1699
  let settled = false;
1685
1700
  const done = (live) => {
1686
1701
  if (settled) return;
@@ -1690,7 +1705,7 @@ function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
1690
1705
  c.destroy();
1691
1706
  } catch {
1692
1707
  }
1693
- resolve(live);
1708
+ resolve2(live);
1694
1709
  };
1695
1710
  const c = net.createConnection(sockPath);
1696
1711
  const timer = setTimeout(() => done(false), timeoutMs);
@@ -1842,7 +1857,7 @@ function startAttachStream(conn, hub, channelId) {
1842
1857
  function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2) {
1843
1858
  let phase = "selecting";
1844
1859
  let channelId = null;
1845
- const fail = (error) => {
1860
+ const fail2 = (error) => {
1846
1861
  phase = "closed";
1847
1862
  writeLine({ error });
1848
1863
  conn.end();
@@ -1850,7 +1865,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
1850
1865
  const doOffer = (id) => {
1851
1866
  const res = ctl.offer(id);
1852
1867
  if (!res.ok) {
1853
- fail(res.error);
1868
+ fail2(res.error);
1854
1869
  return;
1855
1870
  }
1856
1871
  channelId = id;
@@ -1889,14 +1904,14 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
1889
1904
  return;
1890
1905
  }
1891
1906
  if (!res.ok) {
1892
- fail(res.error);
1907
+ fail2(res.error);
1893
1908
  return;
1894
1909
  }
1895
1910
  phase = "spec_sent";
1896
1911
  writeLine({ handoff: "spec", ...res.spec });
1897
1912
  }).catch((e) => {
1898
1913
  log2(`[braid] handoff begin failed: ${e.message}`);
1899
- if (phase !== "closed") fail("begin_failed");
1914
+ if (phase !== "closed") fail2("begin_failed");
1900
1915
  });
1901
1916
  return;
1902
1917
  }
@@ -1915,7 +1930,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
1915
1930
  });
1916
1931
  return;
1917
1932
  }
1918
- fail("protocol_error");
1933
+ fail2("protocol_error");
1919
1934
  };
1920
1935
  }
1921
1936
  function startRecoverConversation(conn, ctl, writeLine, log2) {
@@ -2448,6 +2463,913 @@ function makeSocketHandoffController(deps) {
2448
2463
  };
2449
2464
  }
2450
2465
 
2466
+ // src/gitGateway/gateway.ts
2467
+ import { chmodSync as chmodSync4, existsSync as existsSync6, lstatSync as lstatSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync7, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
2468
+ import { dirname as dirname4, join as join7 } from "path";
2469
+
2470
+ // src/gitGateway/git.ts
2471
+ import { execFile } from "child_process";
2472
+ import { existsSync as existsSync5, lstatSync as lstatSync3, readFileSync as readFileSync6, realpathSync, statSync as statSync2 } from "fs";
2473
+ import { delimiter, isAbsolute, join as join6, resolve, sep as sep2 } from "path";
2474
+ var HARDENED_CONFIG = [
2475
+ ["core.hooksPath", "/dev/null"],
2476
+ ["core.fsmonitor", "false"],
2477
+ ["core.alternateRefsCommand", ""],
2478
+ ["uploadpack.packObjectsHook", ""],
2479
+ ["protocol.ext.allow", "never"],
2480
+ ["protocol.file.allow", "user"],
2481
+ ["protocol.allow", "user"],
2482
+ ["credential.helper", ""],
2483
+ ["core.askPass", ""],
2484
+ ["core.sshCommand", "/usr/bin/false"],
2485
+ ["safe.directory", "*"]
2486
+ ];
2487
+ var FORBIDDEN_EXTRA = /* @__PURE__ */ new Set(["GIT_EXEC_PATH", "GIT_PROXY_COMMAND", "GIT_DIR", "GIT_WORK_TREE", "GIT_EXTERNAL_DIFF", "GIT_SSH"]);
2488
+ function hardenedGitEnv(extra = {}) {
2489
+ const safeExtra = {};
2490
+ for (const [k, v] of Object.entries(extra)) {
2491
+ if (k.startsWith("GIT_CONFIG") || FORBIDDEN_EXTRA.has(k)) continue;
2492
+ safeExtra[k] = v;
2493
+ }
2494
+ const env = {
2495
+ ...safeExtra,
2496
+ PATH: safeExtra.PATH ?? process.env.PATH ?? "/usr/bin:/bin",
2497
+ HOME: safeExtra.HOME ?? process.env.HOME ?? "/",
2498
+ GIT_CONFIG_NOSYSTEM: "1",
2499
+ GIT_CONFIG_GLOBAL: "/dev/null",
2500
+ GIT_TERMINAL_PROMPT: "0",
2501
+ GIT_ASKPASS: "",
2502
+ GCM_INTERACTIVE: "never",
2503
+ GIT_SSH_COMMAND: "/usr/bin/false",
2504
+ GIT_PAGER: "cat",
2505
+ LC_ALL: "C"
2506
+ };
2507
+ HARDENED_CONFIG.forEach(([k, v], i) => {
2508
+ env[`GIT_CONFIG_KEY_${i}`] = k;
2509
+ env[`GIT_CONFIG_VALUE_${i}`] = v;
2510
+ });
2511
+ env.GIT_CONFIG_COUNT = String(HARDENED_CONFIG.length);
2512
+ return env;
2513
+ }
2514
+ var gitBinary = null;
2515
+ function resolveGitBinary() {
2516
+ if (gitBinary) return gitBinary;
2517
+ const candidates = [
2518
+ ...(process.env.PATH ?? "").split(delimiter).filter(Boolean).filter((d) => isAbsolute(d)).map((d) => join6(d, "git")),
2519
+ "/usr/bin/git",
2520
+ "/usr/local/bin/git",
2521
+ "/opt/homebrew/bin/git"
2522
+ ];
2523
+ for (const c of candidates) {
2524
+ try {
2525
+ if (statSync2(c).isFile()) {
2526
+ gitBinary = c;
2527
+ return c;
2528
+ }
2529
+ } catch {
2530
+ }
2531
+ }
2532
+ throw new Error("[git-gateway] git binary not found on PATH or in the usual locations");
2533
+ }
2534
+ var MAX_OUTPUT = 1024 * 1024;
2535
+ async function runGit(args, opts = {}) {
2536
+ for (const a of args) {
2537
+ if (typeof a !== "string") throw new Error("[git-gateway] non-string argv");
2538
+ }
2539
+ const argv = opts.gitDir ? ["--git-dir", opts.gitDir, ...args] : args;
2540
+ return new Promise((resolvePromise, reject) => {
2541
+ const child = execFile(
2542
+ resolveGitBinary(),
2543
+ argv,
2544
+ { cwd: opts.cwd, env: hardenedGitEnv(opts.extraEnv), timeout: opts.timeoutMs ?? 12e4, maxBuffer: MAX_OUTPUT, windowsHide: true, killSignal: "SIGKILL" },
2545
+ (err, stdout, stderr) => {
2546
+ if (err && err.killed) {
2547
+ return reject(new Error(`[git-gateway] git ${args[0] ?? ""} timed out after ${opts.timeoutMs ?? 12e4} ms`));
2548
+ }
2549
+ if (err && typeof err.code !== "number") return reject(err);
2550
+ resolvePromise({ code: err ? Number(err.code) : 0, stdout: String(stdout), stderr: String(stderr) });
2551
+ }
2552
+ );
2553
+ child.stdin?.on("error", () => {
2554
+ });
2555
+ if (opts.input !== void 0) child.stdin?.end(opts.input);
2556
+ else child.stdin?.end();
2557
+ });
2558
+ }
2559
+ function isInside(child, parent) {
2560
+ return child === parent || child.startsWith(parent.endsWith(sep2) ? parent : parent + sep2);
2561
+ }
2562
+ function validateWorktree(path2, writableRoots) {
2563
+ let real;
2564
+ try {
2565
+ real = realpathSync(resolve(path2));
2566
+ } catch {
2567
+ return { ok: false, reason: `path does not exist: ${path2}` };
2568
+ }
2569
+ const roots = writableRoots.map((r) => {
2570
+ try {
2571
+ return realpathSync(r);
2572
+ } catch {
2573
+ return resolve(r);
2574
+ }
2575
+ });
2576
+ if (!roots.some((r) => isInside(real, r))) return { ok: false, reason: `path is outside the sandbox's writable set: ${path2}` };
2577
+ const gitDir = join6(real, ".git");
2578
+ let st;
2579
+ try {
2580
+ st = lstatSync3(gitDir);
2581
+ } catch {
2582
+ return { ok: false, reason: `not a git repository (no .git): ${path2}` };
2583
+ }
2584
+ if (!st.isDirectory()) return { ok: false, reason: `.git is a gitfile or symlink, refused: ${path2}` };
2585
+ if (existsSync5(join6(gitDir, "objects", "info", "alternates"))) return { ok: false, reason: ".git/objects/info/alternates present, refused" };
2586
+ let cfg = "";
2587
+ try {
2588
+ cfg = readFileSync6(join6(gitDir, "config"), "utf8");
2589
+ } catch (e) {
2590
+ if (e.code !== "ENOENT") return { ok: false, reason: ".git/config could not be read, refused" };
2591
+ }
2592
+ if (/^\s*\[include(If)?\b/im.test(cfg)) return { ok: false, reason: ".git/config contains an include section, refused" };
2593
+ return { ok: true, gitDir };
2594
+ }
2595
+
2596
+ // src/gitGateway/gateway.ts
2597
+ var BRANCH_RE = /^(?!-)(?!refs\/)(?!.*\.\.)(?!.*\.lock$)(?!HEAD$)[A-Za-z0-9._\/-]{1,200}$/;
2598
+ var RISK_PATHS = [/^\.gitmodules$/, /^\.gitattributes$/, /^package\.json$/, /^package-lock\.json$/, /^pnpm-lock\.yaml$/, /^yarn\.lock$/, /^Makefile$/, /^[^/]+\.sh$/, /^\.git[^/]*$/];
2599
+ var WORKFLOW_PATH_RE = /^\.github\/workflows\//;
2600
+ var GITHUB_API = "https://api.github.com";
2601
+ var MERGE_METHODS = /* @__PURE__ */ new Set(["merge", "squash", "rebase"]);
2602
+ var LOCAL_TIMEOUT_MS = 12e4;
2603
+ var NETWORK_TIMEOUT_MS = 3e5;
2604
+ function fail(status, code, text) {
2605
+ return { ok: false, status, code, text };
2606
+ }
2607
+ function scrub(s) {
2608
+ return s.replace(/gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|x-access-token:[^@\s]+/g, "[redacted]");
2609
+ }
2610
+ function oneLine(s, max = 300) {
2611
+ return scrub(s).replace(/\s+/g, " ").trim().slice(0, max);
2612
+ }
2613
+ function errText(e) {
2614
+ return oneLine(e instanceof Error ? e.message : String(e));
2615
+ }
2616
+ function shellQuote(p) {
2617
+ return `'${p.replace(/'/g, `'\\''`)}'`;
2618
+ }
2619
+ function credentialHelperArgs(credFile) {
2620
+ return ["-c", "credential.helper=", "-c", `credential.helper=store --file=${shellQuote(credFile)}`];
2621
+ }
2622
+ function safeSegment(s) {
2623
+ return s.replace(/[^a-zA-Z0-9._-]/g, "_");
2624
+ }
2625
+ function logSafe(s) {
2626
+ return s.replace(/[^A-Za-z0-9._\/-]/g, "?").slice(0, 200) || "-";
2627
+ }
2628
+ function isAutonomous(g) {
2629
+ return g.mode === "autonomous";
2630
+ }
2631
+ var CONFIG_INCLUDE_RE = /^\s*\[include(If)?\b/im;
2632
+ function readConfig(gitDir) {
2633
+ try {
2634
+ return readFileSync7(join7(gitDir, "config"), "utf8");
2635
+ } catch (e) {
2636
+ if (e.code === "ENOENT") return "";
2637
+ throw e;
2638
+ }
2639
+ }
2640
+ var GitGateway = class {
2641
+ constructor(deps) {
2642
+ this.deps = deps;
2643
+ this.fetchFn = deps.fetchFn ?? ((...a) => fetch(...a));
2644
+ }
2645
+ deps;
2646
+ /** D15: per-channel disable depth; git is enabled only at depth 0 (overlapping lisa turns nest). */
2647
+ disabledDepth = /* @__PURE__ */ new Map();
2648
+ locks = /* @__PURE__ */ new Map();
2649
+ knownDefault = /* @__PURE__ */ new Map();
2650
+ fetchFn;
2651
+ /** D15: lisa fresh turns (and the critics they spawn) must not use the gateway. */
2652
+ setChannelGitEnabled(channelId, enabled) {
2653
+ const depth = Math.max(0, (this.disabledDepth.get(channelId) ?? 0) + (enabled ? -1 : 1));
2654
+ if (depth === 0) this.disabledDepth.delete(channelId);
2655
+ else this.disabledDepth.set(channelId, depth);
2656
+ }
2657
+ status(channelId, args) {
2658
+ return this.guard("status", () => this.statusInner(channelId, args));
2659
+ }
2660
+ fetch(channelId, args) {
2661
+ return this.guard("fetch", () => this.fetchInner(channelId, args));
2662
+ }
2663
+ async push(channelId, args) {
2664
+ const branch = String(args.branch ?? "");
2665
+ const out = {};
2666
+ const r = await this.guard("push", () => this.pushInner(channelId, branch, args, out));
2667
+ this.log(`push ${out.repo ?? "-"} ${logSafe(branch)} ${out.sha ?? "-"} ${r.ok ? "ok" : `refused:${r.code}`}`);
2668
+ return r;
2669
+ }
2670
+ openPr(channelId, args) {
2671
+ return this.guard("pull request", () => this.openPrInner(channelId, args));
2672
+ }
2673
+ mergePr(channelId, args) {
2674
+ return this.guard("merge", () => this.mergePrInner(channelId, args));
2675
+ }
2676
+ // ---- operations ----
2677
+ async statusInner(channelId, args) {
2678
+ const gate = this.gateChecks(channelId);
2679
+ if (gate) return gate;
2680
+ const grant = await this.singleGrant();
2681
+ if (!grant.ok) return grant;
2682
+ const g = grant.grant;
2683
+ const wt = this.worktree(channelId, args.path);
2684
+ if (!wt.ok) return wt;
2685
+ const opts = { gitDir: wt.gitDir, cwd: wt.path };
2686
+ const br = await this.git(["rev-parse", "--abbrev-ref", "HEAD"], opts);
2687
+ const hd = await this.git(["rev-parse", "--verify", "-q", "HEAD"], opts);
2688
+ const branch = br.code === 0 ? br.stdout.trim() : null;
2689
+ const head = hd.code === 0 ? hd.stdout.trim() : null;
2690
+ let ahead = null;
2691
+ let behind = null;
2692
+ let tracking;
2693
+ if (!head || !branch || branch === "HEAD") {
2694
+ tracking = head ? "HEAD is detached, so there is no braid/<branch> to compare with." : "The worktree has no commits yet.";
2695
+ } else {
2696
+ const ref = `refs/remotes/braid/${branch}`;
2697
+ const has = await this.git(["rev-parse", "--verify", "-q", ref], opts);
2698
+ if (has.code !== 0) {
2699
+ tracking = `There is no fetched copy of braid/${branch}; run braid_git_fetch to compare with the remote.`;
2700
+ } else {
2701
+ const c = await this.git(["rev-list", "--left-right", "--count", `HEAD...${ref}`], opts);
2702
+ const m = /^(\d+)\s+(\d+)/.exec(c.stdout.trim());
2703
+ if (c.code === 0 && m) {
2704
+ ahead = Number(m[1]);
2705
+ behind = Number(m[2]);
2706
+ tracking = `Ahead ${ahead}, behind ${behind} of braid/${branch} (as of the last braid_git_fetch).`;
2707
+ } else {
2708
+ tracking = `Could not compare with braid/${branch}; run braid_git_fetch and try again.`;
2709
+ }
2710
+ }
2711
+ }
2712
+ const repo = `${g.repoOwner}/${g.repoName}`;
2713
+ const mode = isAutonomous(g) ? "autonomous" : "pr-only";
2714
+ const lines = [
2715
+ `Git access: ${repo}, mode ${mode}.`,
2716
+ `Rules: ${this.rulesText(g)}`,
2717
+ `Worktree ${wt.path}: branch ${branch ?? "unknown"}, HEAD ${head ?? "none"}.`,
2718
+ tracking
2719
+ ];
2720
+ return { ok: true, text: lines.join("\n"), data: { repo, mode, branch, head, ahead, behind } };
2721
+ }
2722
+ async fetchInner(channelId, args) {
2723
+ const gate = this.gateChecks(channelId);
2724
+ if (gate) return gate;
2725
+ const grant = await this.singleGrant();
2726
+ if (!grant.ok) return grant;
2727
+ const g = grant.grant;
2728
+ const mirror = this.mirrorPath(g);
2729
+ return this.serialize(mirror, async () => {
2730
+ const gate2 = this.gateChecks(channelId);
2731
+ if (gate2) return gate2;
2732
+ const m = await this.ensureMirror(mirror);
2733
+ if (m) return m;
2734
+ const cred = await this.credentialAndPolicy(g);
2735
+ if (!cred.ok) return cred;
2736
+ const wt = this.worktree(channelId, args.path);
2737
+ if (!wt.ok) return wt;
2738
+ const id = this.identity(wt);
2739
+ if (!id) return this.worktreeChanged("fetch");
2740
+ const remoteUrl = this.remoteUrl(g);
2741
+ const credFile = join7(mirror, ".braid-cred");
2742
+ try {
2743
+ this.writeCredFile(credFile, cred.value.token);
2744
+ const r = await this.git([...credentialHelperArgs(credFile), "fetch", "--quiet", "--no-tags", "--prune", remoteUrl, "+refs/heads/*:refs/remotes/braid/*"], { gitDir: mirror }, true);
2745
+ if (r.code !== 0) return this.remoteRejected(g, r.stderr);
2746
+ } finally {
2747
+ this.removeCredFile(credFile);
2748
+ }
2749
+ if (!this.sameIdentity(wt, id)) return this.worktreeChanged("fetch");
2750
+ const into = await this.git(["fetch", "--quiet", "--no-tags", "--prune", mirror, "+refs/remotes/braid/*:refs/remotes/braid/*"], { gitDir: wt.gitDir, cwd: wt.path }, true);
2751
+ if (into.code !== 0) return fail(502, "fetch_into_worktree_failed", `Fetched ${g.repoOwner}/${g.repoName}, but could not update the worktree's braid refs: ${oneLine(into.stderr)}.`);
2752
+ const list = await this.git(["for-each-ref", "--format=%(refname:short) %(objectname:short=12)", "refs/remotes/braid/"], { gitDir: wt.gitDir, cwd: wt.path });
2753
+ const refs = list.stdout.split("\n").filter(Boolean);
2754
+ const shown = refs.slice(0, 30).join("\n");
2755
+ const more = refs.length > 30 ? `
2756
+ (${refs.length - 30} more)` : "";
2757
+ this.log(`fetch ${g.repoOwner}/${g.repoName} ok`);
2758
+ return {
2759
+ ok: true,
2760
+ text: `Fetched ${g.repoOwner}/${g.repoName} into the worktree as remote braid (${refs.length} branches). Rebase onto braid/<branch> before pushing if the remote moved.
2761
+ ${shown}${more}`,
2762
+ data: { refs: refs.length }
2763
+ };
2764
+ });
2765
+ }
2766
+ /** D8, step by step. */
2767
+ async pushInner(channelId, branch, args, out) {
2768
+ const gate = this.gateChecks(channelId);
2769
+ if (gate) return gate;
2770
+ const grant = await this.singleGrant();
2771
+ if (!grant.ok) return grant;
2772
+ const listed = grant.grant;
2773
+ const repo = `${listed.repoOwner}/${listed.repoName}`;
2774
+ out.repo = repo;
2775
+ const bad = await this.checkBranch(branch);
2776
+ if (bad) return bad;
2777
+ const ns = this.namespace();
2778
+ if (!isAutonomous(listed) && !branch.startsWith(ns)) {
2779
+ const hint = this.knownDefault.get(repo) ?? await this.worktreeDefaultHint(channelId, args.path);
2780
+ return hint === branch ? this.defaultProtected(branch) : this.outsideNamespace();
2781
+ }
2782
+ const mirror = this.mirrorPath(listed);
2783
+ return this.serialize(mirror, async () => {
2784
+ const gate2 = this.gateChecks(channelId);
2785
+ if (gate2) return gate2;
2786
+ const m = await this.ensureMirror(mirror);
2787
+ if (m) return m;
2788
+ const cred = await this.credentialAndPolicy(listed);
2789
+ if (!cred.ok) return cred;
2790
+ const g = cred.grant;
2791
+ const meta = await this.repoMeta(g, cred.value);
2792
+ if (!meta.ok) return meta;
2793
+ const pub = this.publicCheck(g, meta.value);
2794
+ if (pub) return pub;
2795
+ if (!isAutonomous(g)) {
2796
+ if (meta.value.defaultBranch === branch) return this.defaultProtected(branch);
2797
+ if (!branch.startsWith(ns)) return this.outsideNamespace();
2798
+ }
2799
+ const wt = this.worktree(channelId, args.path);
2800
+ if (!wt.ok) return wt;
2801
+ const id = this.identity(wt);
2802
+ if (!id) return this.worktreeChanged("push", mirror);
2803
+ const sha = await this.resolveSha(wt, args.sha ?? "HEAD");
2804
+ if (!sha.ok) return sha;
2805
+ out.sha = sha.sha;
2806
+ if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
2807
+ const inc = await this.git(["fetch", "--quiet", "--no-tags", wt.gitDir, `+${sha.sha}:refs/braid/incoming`], { gitDir: mirror }, true);
2808
+ if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
2809
+ if (inc.code !== 0) return fail(502, "fetch_from_worktree_failed", `Could not read commit ${sha.sha.slice(0, 12)} from the worktree: ${oneLine(inc.stderr)}.`);
2810
+ const remoteUrl = this.remoteUrl(g);
2811
+ const credFile = join7(mirror, ".braid-cred");
2812
+ try {
2813
+ this.writeCredFile(credFile, cred.value.token);
2814
+ const probe = await this.probeRemote(g, mirror, remoteUrl, credFile, branch, meta.value.defaultBranch);
2815
+ if (!probe.ok) return probe;
2816
+ if (!isAutonomous(g) && probe.value.defaultBranch === branch) return this.defaultProtected(branch);
2817
+ if (probe.value.branchExists) {
2818
+ const ff = await this.fastForwardCheck(mirror, branch);
2819
+ if (ff) return ff;
2820
+ }
2821
+ const policy = await this.policyDiff(g, mirror, probe.value.branchExists);
2822
+ if (policy) return policy;
2823
+ if (!this.sameIdentity(wt, id)) return this.worktreeChanged("push", mirror);
2824
+ const pushed = await this.git([...credentialHelperArgs(credFile), "push", "--quiet", "--no-verify", remoteUrl, `refs/braid/incoming:refs/heads/${branch}`], { gitDir: mirror }, true);
2825
+ if (pushed.code !== 0) {
2826
+ if (/\b(401|403)\b|Authentication failed|could not read Username/i.test(pushed.stderr)) this.deps.resolver.dropCredential(listed);
2827
+ return fail(502, "push_failed", `GitHub rejected the push: ${oneLine(pushed.stderr)}. Run braid_git_fetch and braid_git_status, then try again.`);
2828
+ }
2829
+ return {
2830
+ ok: true,
2831
+ text: `Pushed ${sha.sha.slice(0, 12)} to ${repo} branch ${branch}.
2832
+ Compare: https://github.com/${repo}/compare/${encodeURIComponent(branch)}`,
2833
+ data: { branch, sha: sha.sha }
2834
+ };
2835
+ } finally {
2836
+ this.removeCredFile(credFile);
2837
+ }
2838
+ });
2839
+ }
2840
+ async openPrInner(channelId, args) {
2841
+ const gate = this.gateChecks(channelId);
2842
+ if (gate) return gate;
2843
+ const grant = await this.singleGrant();
2844
+ if (!grant.ok) return grant;
2845
+ const branch = String(args.branch ?? "");
2846
+ const badHead = await this.checkBranch(branch);
2847
+ if (badHead) return badHead;
2848
+ const title = String(args.title ?? "").trim();
2849
+ if (!title) return fail(400, "invalid_title", "A pull request needs a non-empty title.");
2850
+ if (args.base !== void 0) {
2851
+ const badBase = await this.checkBranch(String(args.base));
2852
+ if (badBase) return badBase;
2853
+ }
2854
+ const cred = await this.credentialAndPolicy(grant.grant);
2855
+ if (!cred.ok) return cred;
2856
+ const g = cred.grant;
2857
+ const meta = await this.repoMeta(g, cred.value);
2858
+ if (!meta.ok) return meta;
2859
+ const pub = this.publicCheck(g, meta.value);
2860
+ if (pub) return pub;
2861
+ const base = args.base ?? meta.value.defaultBranch;
2862
+ if (!base) return fail(502, "default_branch_unknown", "Could not learn the repository's default branch; pass base explicitly.");
2863
+ const draft = !isAutonomous(g);
2864
+ const body = { title, ...args.body !== void 0 ? { body: String(args.body) } : {}, head: branch, base, draft };
2865
+ const res = await this.api(grant.grant, cred.value.token, "POST", `/repos/${this.repoPath(g)}/pulls`, body);
2866
+ if (!res.ok) return res;
2867
+ const num = res.json.number;
2868
+ const url = res.json.html_url;
2869
+ this.log(`pr-open ${g.repoOwner}/${g.repoName} ${logSafe(branch)} #${String(num)} ok`);
2870
+ return {
2871
+ ok: true,
2872
+ text: `Opened ${draft ? "draft " : ""}pull request #${String(num)} from ${branch} into ${base}: ${String(url)}${draft ? "\nThis repo is in pr-only mode, so a human reviews and merges it." : ""}`,
2873
+ data: { number: num, url }
2874
+ };
2875
+ }
2876
+ async mergePrInner(channelId, args) {
2877
+ const gate = this.gateChecks(channelId);
2878
+ if (gate) return gate;
2879
+ const grant = await this.singleGrant();
2880
+ if (!grant.ok) return grant;
2881
+ const num = Number(args.number);
2882
+ if (!Number.isInteger(num) || num <= 0) return fail(400, "invalid_number", "Pass the pull request number as a positive integer.");
2883
+ const method = args.method ?? "squash";
2884
+ if (!MERGE_METHODS.has(method)) return fail(400, "invalid_method", "The merge method must be merge, squash or rebase.");
2885
+ if (!isAutonomous(grant.grant)) return this.mergeRefused(num);
2886
+ const cred = await this.credentialAndPolicy(grant.grant);
2887
+ if (!cred.ok) return cred;
2888
+ const g = cred.grant;
2889
+ if (!isAutonomous(g)) return this.mergeRefused(num);
2890
+ const meta = await this.repoMeta(g, cred.value);
2891
+ if (!meta.ok) return meta;
2892
+ const pub = this.publicCheck(g, meta.value);
2893
+ if (pub) return pub;
2894
+ const res = await this.api(grant.grant, cred.value.token, "PUT", `/repos/${this.repoPath(g)}/pulls/${num}/merge`, { merge_method: method });
2895
+ if (!res.ok) return res;
2896
+ const sha = String(res.json.sha ?? "");
2897
+ this.log(`pr-merge ${g.repoOwner}/${g.repoName} #${num} ok`);
2898
+ return { ok: true, text: `Pull request #${num} in ${g.repoOwner}/${g.repoName} merged ${sha} (${method}).`, data: { number: num, sha } };
2899
+ }
2900
+ // ---- push pipeline steps ----
2901
+ /** D8.5: one ls-remote learns whether the branch exists (and proves the token works);
2902
+ * then the branch, or for a new branch the default branch, is fetched into the mirror. */
2903
+ async probeRemote(g, mirror, remoteUrl, credFile, branch, knownDefault) {
2904
+ const ls = await this.git([...credentialHelperArgs(credFile), "ls-remote", "--symref", remoteUrl, "HEAD", `refs/heads/${branch}`], { gitDir: mirror }, true);
2905
+ if (ls.code !== 0) return this.remoteRejected(g, ls.stderr);
2906
+ const branchExists = ls.stdout.split("\n").some((l) => l.endsWith(` refs/heads/${branch}`));
2907
+ const symref = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(ls.stdout);
2908
+ const defaultBranch = knownDefault ?? (symref ? symref[1] : null);
2909
+ if (defaultBranch) this.knownDefault.set(`${g.repoOwner}/${g.repoName}`, defaultBranch);
2910
+ await this.git(["update-ref", "-d", "refs/braid/remote"], { gitDir: mirror });
2911
+ await this.git(["update-ref", "-d", "refs/braid/base"], { gitDir: mirror });
2912
+ const specs = [];
2913
+ if (branchExists) specs.push(`+refs/heads/${branch}:refs/braid/remote`);
2914
+ else if (defaultBranch && BRANCH_RE.test(defaultBranch)) specs.push(`+refs/heads/${defaultBranch}:refs/braid/base`);
2915
+ if (specs.length > 0) {
2916
+ const rf = await this.git([...credentialHelperArgs(credFile), "fetch", "--quiet", "--no-tags", remoteUrl, ...specs], { gitDir: mirror }, true);
2917
+ if (rf.code !== 0) return this.remoteRejected(g, rf.stderr);
2918
+ }
2919
+ return { ok: true, value: { branchExists, defaultBranch } };
2920
+ }
2921
+ /** D8.6: never --force; the remote tip must be an ancestor of what we push. */
2922
+ async fastForwardCheck(mirror, branch) {
2923
+ const ff = await this.git(["merge-base", "--is-ancestor", "refs/braid/remote", "refs/braid/incoming"], { gitDir: mirror });
2924
+ if (ff.code === 0) return null;
2925
+ return fail(409, "not_fast_forward", `Remote ${branch} has commits you do not have. Run braid_git_fetch, rebase onto braid/${branch}, then push again.`);
2926
+ }
2927
+ /** D8.8 (ruling R12): the changed paths, against the remote branch when it exists,
2928
+ * else against the merge-base with the default branch, else (orphan) the empty tree. */
2929
+ async policyDiff(g, mirror, branchExists) {
2930
+ const base = branchExists ? "refs/braid/remote" : await this.newBranchBase(mirror);
2931
+ if (!base) return fail(502, "diff_failed", "Could not compute what this push changes; try again.");
2932
+ const diff = await this.git(["diff", "--no-renames", "--name-only", "-z", base, "refs/braid/incoming", "--"], { gitDir: mirror });
2933
+ if (diff.code !== 0) return fail(502, "diff_failed", `Could not compute what this push changes: ${oneLine(diff.stderr)}.`);
2934
+ const files = diff.stdout.split("\0").filter(Boolean);
2935
+ if (!g.allowWorkflows && files.some((f) => WORKFLOW_PATH_RE.test(f))) return fail(403, "workflows_not_allowed", "This push changes .github/workflows and the grant does not allow workflow changes. Remove those changes, or ask the owner to enable workflow changes on the grant.");
2936
+ const risky = files.filter((f) => RISK_PATHS.some((re) => re.test(f)));
2937
+ if (!isAutonomous(g) && !g.allowRiskPaths && risky.length > 0) return fail(403, "risk_path_needs_approval", `This push touches files the owner must approve in pr-only mode (${risky.slice(0, 20).join(", ")}). Remove those changes, or ask the owner to enable "risky file changes" on the grant.`);
2938
+ return null;
2939
+ }
2940
+ async newBranchBase(mirror) {
2941
+ const has = await this.git(["rev-parse", "--verify", "-q", "refs/braid/base"], { gitDir: mirror });
2942
+ if (has.code === 0) {
2943
+ const mb = await this.git(["merge-base", "refs/braid/base", "refs/braid/incoming"], { gitDir: mirror });
2944
+ const sha = mb.stdout.trim();
2945
+ if (mb.code === 0 && sha) return sha;
2946
+ }
2947
+ const empty = await this.git(["hash-object", "-t", "tree", "--stdin"], { gitDir: mirror, input: "" });
2948
+ return empty.code === 0 ? empty.stdout.trim() : null;
2949
+ }
2950
+ // ---- helpers ----
2951
+ git(args, opts, network = false) {
2952
+ return runGit(args, { ...opts, timeoutMs: this.deps.gitTimeoutMs ?? (network ? NETWORK_TIMEOUT_MS : LOCAL_TIMEOUT_MS) });
2953
+ }
2954
+ /** G11: a thrown git or network failure still comes back as a sentence. */
2955
+ async guard(op, fn) {
2956
+ try {
2957
+ return await fn();
2958
+ } catch (e) {
2959
+ return fail(502, "git_failed", `The ${op} could not finish because a git or network step failed (${errText(e)}). Try again; if it keeps failing, tell the owner.`);
2960
+ }
2961
+ }
2962
+ gateChecks(channelId) {
2963
+ if (this.disabledDepth.has(channelId)) return fail(403, "git_disabled_for_turn", "Git tools are disabled for this turn. Describe the change in your reply instead, and a later turn can push it.");
2964
+ return null;
2965
+ }
2966
+ /** D7: one grant per agent for now; the repo is never chosen by the model. */
2967
+ async singleGrant() {
2968
+ let grants;
2969
+ try {
2970
+ grants = await this.deps.resolver.listGrants();
2971
+ } catch (e) {
2972
+ return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
2973
+ }
2974
+ if (grants.length === 0) return fail(404, "no_grant", `No git grant for this agent. Your owner can add one under Agents > ${this.deps.agentName} > Git access.`);
2975
+ if (grants.length > 1) return fail(409, "multiple_grants", `This agent has ${grants.length} git grants, and the gateway supports exactly one today. Ask the owner to keep one repository under Agents > ${this.deps.agentName} > Git access.`);
2976
+ return { ok: true, grant: grants[0] };
2977
+ }
2978
+ /** Credential plus the effective policy. When the listed grant is LOOSER than the
2979
+ * policy the cached mint carries (the owner loosened the grant after the mint), the
2980
+ * cached credential is dropped and re-minted once so the loosening takes effect now
2981
+ * instead of at the next scheduled re-mint; the result is still combined
2982
+ * most-restrictively, so a relay that really is tighter keeps winning. */
2983
+ async credentialAndPolicy(g) {
2984
+ let cred = await this.credential(g);
2985
+ if (!cred.ok) return cred;
2986
+ if (cred.value.policy && this.looser(g, cred.value.policy)) {
2987
+ this.deps.resolver.dropCredential(g);
2988
+ cred = await this.credential(g);
2989
+ if (!cred.ok) return cred;
2990
+ }
2991
+ const pol = this.effectivePolicy(g, cred.value);
2992
+ if (!pol.ok) return pol;
2993
+ return { ok: true, value: cred.value, grant: pol.grant };
2994
+ }
2995
+ looser(g, p) {
2996
+ return isAutonomous(g) && p.mode !== "autonomous" || g.allowWorkflows && !p.allowWorkflows || g.allowRiskPaths && !p.allowRiskPaths || g.allowPublic && !p.allowPublic;
2997
+ }
2998
+ /** The mint's policy and the listed grant are combined most-restrictively, so a
2999
+ * tightening seen by either source takes effect at once. The repos must agree. */
3000
+ effectivePolicy(g, cred) {
3001
+ const p = cred.policy;
3002
+ if (!p) return { ok: true, grant: g };
3003
+ if (p.repo.toLowerCase() !== `${g.repoOwner}/${g.repoName}`.toLowerCase()) {
3004
+ this.deps.resolver.dropCredential(g);
3005
+ return fail(502, "credential_unavailable", `The token minted for this agent's grant does not match ${g.repoOwner}/${g.repoName}, so it was not used. Try again in a minute; if it keeps failing, ask the owner to check Git access for this agent.`);
3006
+ }
3007
+ return {
3008
+ ok: true,
3009
+ grant: {
3010
+ ...g,
3011
+ mode: isAutonomous(g) && p.mode === "autonomous" ? "autonomous" : "pr-only",
3012
+ allowWorkflows: g.allowWorkflows && p.allowWorkflows,
3013
+ allowRiskPaths: g.allowRiskPaths && p.allowRiskPaths,
3014
+ allowPublic: g.allowPublic && p.allowPublic
3015
+ }
3016
+ };
3017
+ }
3018
+ /** Validates the model-supplied path, then uses only the realpath-resolved location. */
3019
+ worktree(channelId, path2) {
3020
+ const p = path2 ?? this.deps.cwdFor(channelId);
3021
+ const v = validateWorktree(p, this.deps.writableRoots());
3022
+ if (!v.ok) return fail(400, "invalid_path", `That path cannot be used for git (${v.reason}). Pass the path of a git checkout inside your working directory, or omit path to use the session directory.`);
3023
+ return { ok: true, path: dirname4(v.gitDir), gitDir: v.gitDir };
3024
+ }
3025
+ /** R13: what must not change between validation and the last use of the worktree. */
3026
+ identity(wt) {
3027
+ try {
3028
+ const d = lstatSync4(wt.path);
3029
+ const gd = lstatSync4(wt.gitDir);
3030
+ if (!d.isDirectory() || !gd.isDirectory()) return null;
3031
+ if (existsSync6(join7(wt.gitDir, "objects", "info", "alternates"))) return null;
3032
+ if (CONFIG_INCLUDE_RE.test(readConfig(wt.gitDir))) return null;
3033
+ return { dirDev: d.dev, dirIno: d.ino, gitDev: gd.dev, gitIno: gd.ino };
3034
+ } catch {
3035
+ return null;
3036
+ }
3037
+ }
3038
+ sameIdentity(wt, before) {
3039
+ const now = this.identity(wt);
3040
+ return !!before && !!now && before.dirDev === now.dirDev && before.dirIno === now.dirIno && before.gitDev === now.gitDev && before.gitIno === now.gitIno;
3041
+ }
3042
+ async worktreeChanged(op, mirror) {
3043
+ if (mirror) await this.git(["update-ref", "-d", "refs/braid/incoming"], { gitDir: mirror });
3044
+ return fail(400, "invalid_path", `The worktree changed while the ${op} was running (its .git was replaced or gained alternates), so nothing was ${op === "push" ? "pushed" : "fetched into it"}. Make sure nothing else is modifying the repository, then try again.`);
3045
+ }
3046
+ /** Only a refusal-text hint: the worktree's origin/HEAD, if the path validates. */
3047
+ async worktreeDefaultHint(channelId, path2) {
3048
+ const wt = this.worktree(channelId, path2);
3049
+ if (!wt.ok) return null;
3050
+ const r = await this.git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], { gitDir: wt.gitDir, cwd: wt.path });
3051
+ const s = r.stdout.trim();
3052
+ return r.code === 0 && s.startsWith("origin/") ? s.slice("origin/".length) : null;
3053
+ }
3054
+ async credential(g) {
3055
+ try {
3056
+ return { ok: true, value: await this.deps.resolver.credentialFor(g) };
3057
+ } catch (e) {
3058
+ return fail(502, "credential_unavailable", `Could not get a GitHub credential for ${g.repoOwner}/${g.repoName} (${errText(e)}). Try again in a minute; if it keeps failing, ask the owner to check Git access for this agent.`);
3059
+ }
3060
+ }
3061
+ /** Visibility and default branch: from the mint for app grants; looked up for a PAT. */
3062
+ async repoMeta(g, cred) {
3063
+ let meta;
3064
+ if (cred.private !== null && cred.defaultBranch) {
3065
+ meta = { private: cred.private, defaultBranch: cred.defaultBranch };
3066
+ } else {
3067
+ const res = await this.api(g, cred.token, "GET", `/repos/${this.repoPath(g)}`);
3068
+ if (!res.ok) return res;
3069
+ const j = res.json;
3070
+ meta = {
3071
+ private: cred.private ?? (typeof j.private === "boolean" ? j.private : null),
3072
+ defaultBranch: cred.defaultBranch ?? (typeof j.default_branch === "string" ? j.default_branch : null)
3073
+ };
3074
+ }
3075
+ if (meta.defaultBranch) this.knownDefault.set(`${g.repoOwner}/${g.repoName}`, meta.defaultBranch);
3076
+ return { ok: true, value: meta };
3077
+ }
3078
+ /** D9: fail closed when visibility is unknown. */
3079
+ publicCheck(g, meta) {
3080
+ if (meta.private === true || g.allowPublic) return null;
3081
+ const why = meta.private === false ? "is public" : "has unknown visibility";
3082
+ return fail(403, "public_repo_not_allowed", `${g.repoOwner}/${g.repoName} ${why}, and the grant does not allow writes to public repositories. Ask the owner to allow public repositories on the grant if this is intended.`);
3083
+ }
3084
+ /** D8.2: the regex, then git's own rules (trailing "/", "//", trailing ".", "/.x", "@{"). */
3085
+ async checkBranch(branch) {
3086
+ const bad = fail(400, "invalid_branch", `Branch name "${branch.slice(0, 200)}" is not allowed. Use letters, digits, ".", "_", "-" and "/", without "..", "//", a leading "-", a trailing "/" or ".", a component starting with ".", a ".lock" suffix, a "refs/" prefix, or the name HEAD.`);
3087
+ if (!BRANCH_RE.test(branch)) return bad;
3088
+ const r = await this.git(["check-ref-format", "--branch", branch], {});
3089
+ return r.code === 0 ? null : bad;
3090
+ }
3091
+ defaultProtected(branch) {
3092
+ return fail(403, "default_branch_protected", `"${branch}" is the default branch; pr-only mode never pushes there. Push to a branch under ${this.namespace()} and open a pull request instead.`);
3093
+ }
3094
+ outsideNamespace() {
3095
+ const ns = this.namespace();
3096
+ return fail(403, "branch_outside_namespace", `This repo is in pr-only mode: push only to branches under ${ns} (for example ${ns}my-change). A human merges from a pull request.`);
3097
+ }
3098
+ mergeRefused(num) {
3099
+ return fail(403, "merge_not_allowed_in_pr_only", `This repo is in pr-only mode, so a human merges pull request #${num}. Tell the owner it is ready for review.`);
3100
+ }
3101
+ namespace() {
3102
+ return `braid/${this.deps.agentName}/`;
3103
+ }
3104
+ rulesText(g) {
3105
+ const parts = [];
3106
+ const auto = isAutonomous(g);
3107
+ if (!auto) parts.push(`push only to branches under ${this.namespace()}, never the default branch; pull requests open as drafts and a human merges`);
3108
+ else parts.push("push to any branch, including the default branch; braid_merge_pr is allowed");
3109
+ parts.push("fast-forward pushes only, no deletes, no tags");
3110
+ parts.push(g.allowWorkflows ? "changes under .github/workflows are allowed" : "changes under .github/workflows are refused");
3111
+ if (!auto) parts.push(g.allowRiskPaths ? "risky files (package manifests, lockfiles, Makefile, root scripts, .git* files) are allowed" : "risky files (package manifests, lockfiles, Makefile, root scripts, .git* files) need the owner's approval");
3112
+ parts.push(g.allowPublic ? "public repositories are allowed" : "writes to a public repository are refused");
3113
+ return parts.join("; ") + ".";
3114
+ }
3115
+ async resolveSha(wt, rev) {
3116
+ const bad = fail(400, "invalid_sha", `Could not resolve "${String(rev).slice(0, 100)}" to a commit in ${wt.path}. Commit your work first, or pass a commit sha or branch name that exists there.`);
3117
+ if (typeof rev !== "string" || !rev || rev.startsWith("-") || /[\0\n]/.test(rev)) return bad;
3118
+ const r = await this.git(["rev-parse", "--verify", "-q", "--end-of-options", `${rev}^{commit}`], { gitDir: wt.gitDir, cwd: wt.path });
3119
+ const sha = r.stdout.trim();
3120
+ if (r.code !== 0 || !/^[0-9a-f]{40,64}$/.test(sha)) return bad;
3121
+ return { ok: true, sha };
3122
+ }
3123
+ mirrorPath(g) {
3124
+ return join7(this.deps.configDir, "git", relayHostSegment(this.deps.relayUrl), safeSegment(this.deps.agentName), `${safeSegment(g.repoOwner)}__${safeSegment(g.repoName)}.git`);
3125
+ }
3126
+ /** Creates the bare mirror (0700) when absent and removes any stale credential file. */
3127
+ async ensureMirror(mirror) {
3128
+ if (!existsSync6(join7(mirror, "HEAD"))) {
3129
+ mkdirSync5(mirror, { recursive: true, mode: 448 });
3130
+ const r = await this.git(["init", "--bare", "--quiet", mirror], {});
3131
+ if (r.code !== 0) return fail(502, "mirror_init_failed", `Could not prepare the bridge's git mirror: ${oneLine(r.stderr)}.`);
3132
+ }
3133
+ try {
3134
+ chmodSync4(mirror, 448);
3135
+ } catch {
3136
+ }
3137
+ this.removeCredFile(join7(mirror, ".braid-cred"));
3138
+ return null;
3139
+ }
3140
+ remoteUrl(g) {
3141
+ return this.deps.remoteUrlFor ? this.deps.remoteUrlFor(g) : `https://github.com/${this.repoPath(g)}.git`;
3142
+ }
3143
+ repoPath(g) {
3144
+ return `${encodeURIComponent(g.repoOwner)}/${encodeURIComponent(g.repoName)}`;
3145
+ }
3146
+ writeCredFile(file, token) {
3147
+ writeFileSync5(file, `https://x-access-token:${token}@github.com
3148
+ `, { mode: 384 });
3149
+ try {
3150
+ chmodSync4(file, 384);
3151
+ } catch {
3152
+ }
3153
+ }
3154
+ removeCredFile(file) {
3155
+ if (!existsSync6(file)) return;
3156
+ try {
3157
+ writeFileSync5(file, "");
3158
+ } catch {
3159
+ }
3160
+ try {
3161
+ unlinkSync3(file);
3162
+ } catch {
3163
+ }
3164
+ }
3165
+ remoteRejected(g, stderr) {
3166
+ this.deps.resolver.dropCredential(g);
3167
+ return fail(502, "remote_rejected", `GitHub refused the request for ${g.repoOwner}/${g.repoName} or could not be reached (${oneLine(stderr, 200)}). The cached token was dropped and a fresh one is minted on the next try; if it keeps failing, ask the owner to check Git access for this agent.`);
3168
+ }
3169
+ async api(g, token, method, path2, body) {
3170
+ let res;
3171
+ try {
3172
+ res = await this.fetchFn(`${GITHUB_API}${path2}`, {
3173
+ method,
3174
+ headers: {
3175
+ Authorization: `Bearer ${token}`,
3176
+ Accept: "application/vnd.github+json",
3177
+ "X-GitHub-Api-Version": "2022-11-28",
3178
+ "User-Agent": "braid-bridge",
3179
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {}
3180
+ },
3181
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
3182
+ });
3183
+ } catch (e) {
3184
+ return fail(502, "github_unreachable", `Could not reach the GitHub API (${errText(e)}). Try again in a minute.`);
3185
+ }
3186
+ let json = null;
3187
+ try {
3188
+ json = await res.json();
3189
+ } catch {
3190
+ json = null;
3191
+ }
3192
+ if (!res.ok) {
3193
+ if (res.status === 401) this.deps.resolver.dropCredential(g);
3194
+ const msg = json && typeof json === "object" && typeof json.message === "string" ? json.message : res.statusText;
3195
+ const errs = json && typeof json === "object" && Array.isArray(json.errors) ? json.errors.map((e) => typeof e?.message === "string" ? e.message : "").filter(Boolean).join("; ") : "";
3196
+ return fail(502, "github_rejected", `GitHub answered ${res.status} for ${g.repoOwner}/${g.repoName}: ${oneLine(msg + (errs ? ` (${errs})` : ""))}. Fix the request or check the pull request on GitHub, then try again.`);
3197
+ }
3198
+ return { ok: true, json: json ?? {} };
3199
+ }
3200
+ /** One mirror operation at a time: the incoming/remote refs and the credential file are shared. */
3201
+ serialize(key, fn) {
3202
+ const prev = this.locks.get(key) ?? Promise.resolve();
3203
+ const run = prev.then(fn, fn);
3204
+ const tail = run.then(() => void 0, () => void 0);
3205
+ this.locks.set(key, tail);
3206
+ void tail.then(() => {
3207
+ if (this.locks.get(key) === tail) this.locks.delete(key);
3208
+ });
3209
+ return run;
3210
+ }
3211
+ log(line) {
3212
+ const l = scrub(line);
3213
+ if (this.deps.log) this.deps.log(l);
3214
+ else console.log(`[git-gateway] ${l}`);
3215
+ }
3216
+ };
3217
+
3218
+ // src/gitGateway/grants.ts
3219
+ import { chmodSync as chmodSync5, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync6 } from "fs";
3220
+ import { dirname as dirname5, join as join8 } from "path";
3221
+ var GRANTS_TTL_MS = 6e4;
3222
+ var REMINT_WINDOW_MS = 5 * 6e4;
3223
+ function localGitGrantPath(dir, relayUrl, agentName) {
3224
+ const safeName = agentName.replace(/[^a-zA-Z0-9._-]/g, "_");
3225
+ return join8(dir, "git-grants", relayHostSegment(relayUrl), `${safeName}.json`);
3226
+ }
3227
+ function loadLocalGitGrant(dir, relayUrl, agentName) {
3228
+ let raw;
3229
+ try {
3230
+ raw = JSON.parse(readFileSync8(localGitGrantPath(dir, relayUrl, agentName), "utf8"));
3231
+ } catch {
3232
+ return null;
3233
+ }
3234
+ const o = raw;
3235
+ if (!o || typeof o !== "object") return null;
3236
+ if (typeof o.token !== "string" || !o.token) return null;
3237
+ if (typeof o.repoOwner !== "string" || !o.repoOwner || typeof o.repoName !== "string" || !o.repoName) return null;
3238
+ if (o.mode !== "autonomous" && o.mode !== "pr-only") return null;
3239
+ return {
3240
+ source: "pat",
3241
+ repoOwner: o.repoOwner,
3242
+ repoName: o.repoName,
3243
+ mode: o.mode,
3244
+ allowWorkflows: o.allowWorkflows === true,
3245
+ allowRiskPaths: o.allowRiskPaths === true,
3246
+ allowPublic: o.allowPublic === true,
3247
+ token: o.token
3248
+ };
3249
+ }
3250
+ function saveLocalGitGrant(dir, relayUrl, agentName, grant, deps) {
3251
+ const file = localGitGrantPath(dir, relayUrl, agentName);
3252
+ mkdirSync6(dirname5(file), { recursive: true, mode: 448 });
3253
+ const body = {
3254
+ repoOwner: grant.repoOwner,
3255
+ repoName: grant.repoName,
3256
+ mode: grant.mode,
3257
+ allowWorkflows: grant.allowWorkflows,
3258
+ allowRiskPaths: grant.allowRiskPaths,
3259
+ allowPublic: grant.allowPublic,
3260
+ token: grant.token
3261
+ };
3262
+ const tmp = `${file}.tmp-${process.pid}`;
3263
+ try {
3264
+ writeFileSync6(tmp, JSON.stringify(body, null, 2) + "\n", { mode: 384 });
3265
+ renameSync3(tmp, file);
3266
+ } catch (err) {
3267
+ try {
3268
+ unlinkSync4(tmp);
3269
+ } catch {
3270
+ }
3271
+ throw err;
3272
+ }
3273
+ try {
3274
+ chmodSync5(file, 384);
3275
+ } catch {
3276
+ }
3277
+ const platform = deps?.platform ?? process.platform;
3278
+ if (platform === "win32") {
3279
+ const d = deps ?? productionWin32Deps();
3280
+ try {
3281
+ applyWin32Acl(file, resolveCurrentUser(d).sid, "file", d);
3282
+ } catch (err) {
3283
+ console.warn(`[braid] warn: could not set ACL on git grant file ${file} (startup gate will enforce on next start): ${err instanceof Error ? err.message : String(err)}`);
3284
+ }
3285
+ }
3286
+ return file;
3287
+ }
3288
+ function clearLocalGitGrant(dir, relayUrl, agentName) {
3289
+ const file = localGitGrantPath(dir, relayUrl, agentName);
3290
+ if (!existsSync7(file)) return false;
3291
+ try {
3292
+ unlinkSync4(file);
3293
+ return true;
3294
+ } catch {
3295
+ return false;
3296
+ }
3297
+ }
3298
+ var GrantResolver = class {
3299
+ constructor(deps) {
3300
+ this.deps = deps;
3301
+ this.now = deps.now ?? Date.now;
3302
+ }
3303
+ deps;
3304
+ grantsCache = null;
3305
+ // #private so an inspect/dump of the resolver can never print a token.
3306
+ #creds = /* @__PURE__ */ new Map();
3307
+ #pending = /* @__PURE__ */ new Map();
3308
+ now;
3309
+ async listGrants() {
3310
+ const out = [];
3311
+ if (this.deps.relay) {
3312
+ const t = this.now();
3313
+ if (!this.grantsCache || t - this.grantsCache.at >= GRANTS_TTL_MS) {
3314
+ const fresh = await this.deps.relay.fetchGitGrants();
3315
+ if (fresh !== null) this.grantsCache = { at: t, grants: fresh };
3316
+ }
3317
+ if (this.grantsCache) out.push(...this.grantsCache.grants);
3318
+ }
3319
+ const pat = loadLocalGitGrant(this.deps.configDir, this.deps.relayUrl, this.deps.agentName);
3320
+ if (pat) {
3321
+ const { token: _token, ...info } = pat;
3322
+ out.push(info);
3323
+ }
3324
+ return out;
3325
+ }
3326
+ async grantForRepo(owner, name) {
3327
+ const o = owner.toLowerCase();
3328
+ const n = name.toLowerCase();
3329
+ const all = await this.listGrants();
3330
+ return all.find((g) => g.repoOwner.toLowerCase() === o && g.repoName.toLowerCase() === n) ?? null;
3331
+ }
3332
+ async credentialFor(grant) {
3333
+ if (grant.source === "pat") {
3334
+ const pat = loadLocalGitGrant(this.deps.configDir, this.deps.relayUrl, this.deps.agentName);
3335
+ if (!pat) throw new Error("git grant: the local PAT grant is no longer present");
3336
+ if (pat.repoOwner.toLowerCase() !== grant.repoOwner.toLowerCase() || pat.repoName.toLowerCase() !== grant.repoName.toLowerCase()) {
3337
+ throw new Error(`git grant: the local PAT grant does not cover ${grant.repoOwner}/${grant.repoName}`);
3338
+ }
3339
+ return { token: pat.token, expiresAt: Infinity, private: null, defaultBranch: null };
3340
+ }
3341
+ const id = grant.id;
3342
+ if (!id || !this.deps.relay) throw new Error("git grant: no relay available to mint a token for this grant");
3343
+ const cached = this.#creds.get(id);
3344
+ if (cached && cached.expiresAt - this.now() > REMINT_WINDOW_MS) return cached;
3345
+ const inflight = this.#pending.get(id);
3346
+ if (inflight) return inflight;
3347
+ const relay = this.deps.relay;
3348
+ const p = (async () => {
3349
+ const m = await relay.mintGitToken(id);
3350
+ if (!m) throw new Error(`git grant: the relay would not mint a token for ${grant.repoOwner}/${grant.repoName}`);
3351
+ const cred = {
3352
+ token: m.token,
3353
+ expiresAt: m.expiresAt,
3354
+ private: m.private,
3355
+ defaultBranch: m.defaultBranch,
3356
+ policy: { repo: m.repo, mode: m.mode, allowWorkflows: m.allowWorkflows, allowRiskPaths: m.allowRiskPaths, allowPublic: m.allowPublic }
3357
+ };
3358
+ this.#creds.set(id, cred);
3359
+ return cred;
3360
+ })();
3361
+ this.#pending.set(id, p);
3362
+ try {
3363
+ return await p;
3364
+ } finally {
3365
+ this.#pending.delete(id);
3366
+ }
3367
+ }
3368
+ dropCredential(grant) {
3369
+ if (grant.id) this.#creds.delete(grant.id);
3370
+ }
3371
+ };
3372
+
2451
3373
  // src/bridge.ts
2452
3374
  var REFRESH_MARGIN_MS = 24 * 60 * 60 * 1e3;
2453
3375
  var MIN_DELAY_MS = 60 * 1e3;
@@ -2459,6 +3381,15 @@ function computeRefreshDelay(exp, now) {
2459
3381
  }
2460
3382
  async function startBridge(cfg, relay, deps) {
2461
3383
  const sessions = /* @__PURE__ */ new Map();
3384
+ const gitConfigDir = configDir(process.env);
3385
+ const gitGateway = deps.gitGateway ?? new GitGateway({
3386
+ resolver: new GrantResolver({ relay, configDir: gitConfigDir, relayUrl: cfg.relayWsUrl, agentName: cfg.agentName }),
3387
+ configDir: gitConfigDir,
3388
+ relayUrl: cfg.relayWsUrl,
3389
+ agentName: cfg.agentName,
3390
+ cwdFor: (ch) => handoffCwdFor(cfg, ch),
3391
+ writableRoots: () => resolveScope(cfg, cfg.agent, process.env).runtimeConfig.filesystem.allowWrite
3392
+ });
2462
3393
  const channelNames = /* @__PURE__ */ new Map();
2463
3394
  const hub = new ObservationHub();
2464
3395
  const service = process.env.BRAID_SERVICE === "1";
@@ -2676,7 +3607,11 @@ async function startBridge(cfg, relay, deps) {
2676
3607
  // merged ChannelSession constructor param order (session.ts).
2677
3608
  deps.makeEphemeralAdapter ? () => deps.makeEphemeralAdapter(cfg) : void 0
2678
3609
  );
2679
- const broker = new SourceBroker(channelId, relay);
3610
+ const broker = new SourceBroker(channelId, relay, process.platform, gitGateway);
3611
+ session.onLisaFreshTurn = (active) => {
3612
+ broker.setGitEnabled(!active);
3613
+ gitGateway.setChannelGitEnabled(channelId, !active);
3614
+ };
2680
3615
  const brokerInfo = await broker.start();
2681
3616
  const allowedReactions = await relay.fetchAllowedReactions?.().catch(() => []) ?? [];
2682
3617
  const mcpServers = [{
@@ -3015,10 +3950,11 @@ function startUpdateNotifier(opts) {
3015
3950
  }
3016
3951
 
3017
3952
  // src/cli.ts
3018
- import { readFileSync as readFileSync9 } from "fs";
3953
+ import { readFileSync as readFileSync12 } from "fs";
3019
3954
  import { fileURLToPath as fileURLToPath3 } from "url";
3020
3955
 
3021
3956
  // src/cliArgs.ts
3957
+ var GIT_REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
3022
3958
  var USAGE = `Usage: braid <command>
3023
3959
 
3024
3960
  Commands:
@@ -3048,6 +3984,14 @@ Commands:
3048
3984
  scope allow-code-hosts <agent>
3049
3985
  Let THIS agent's sandbox reach github.com/gitlab.com/bitbucket.org
3050
3986
  directly (accepts the exfiltration risk; prefer the git tools)
3987
+ git-auth set <agent> <owner/repo> --mode <autonomous|pr-only>
3988
+ [--allow-workflows] [--allow-risk-paths] [--allow-public] --token-stdin
3989
+ Give a saved agent git access to ONE repo with a fine-grained
3990
+ token read from stdin (local fallback; prefer the Braid GitHub
3991
+ App in the web app). autonomous = push anywhere and merge;
3992
+ pr-only = braid/<agent>/* branches, a human merges.
3993
+ git-auth show <agent> Show the agent's local git grant (never the token)
3994
+ git-auth clear <agent> Remove the agent's local git grant
3051
3995
  telemetry [status|enable|disable]
3052
3996
  Show or change usage telemetry (tool-mode +
3053
3997
  blocked-tool-call signals only; never content, paths, or
@@ -3241,6 +4185,71 @@ function parseCliArgs(argv) {
3241
4185
  if (first === "version" || first === "--version" || first === "-v") {
3242
4186
  return { kind: "version" };
3243
4187
  }
4188
+ if (first === "git-auth") {
4189
+ const verb = argv[1];
4190
+ const rest = argv.slice(2);
4191
+ if (verb === "show" || verb === "clear") {
4192
+ const agent2 = rest[0] && !rest[0].startsWith("-") ? rest[0].trim() : void 0;
4193
+ if (!agent2 || rest.length > 1) return { kind: "usage", error: `Usage: braid git-auth ${verb} <agent>` };
4194
+ return verb === "show" ? { kind: "git-auth-show", agent: agent2 } : { kind: "git-auth-clear", agent: agent2 };
4195
+ }
4196
+ if (verb !== "set") return { kind: "usage", error: "Usage: braid git-auth <set|show|clear> <agent> ..." };
4197
+ let agent;
4198
+ let repo;
4199
+ let mode;
4200
+ let tokenStdin = false;
4201
+ let allowWorkflows = false;
4202
+ let allowRiskPaths = false;
4203
+ let allowPublic = false;
4204
+ for (let i = 0; i < rest.length; i++) {
4205
+ const a = rest[i];
4206
+ if (a === "--token-stdin") {
4207
+ tokenStdin = true;
4208
+ continue;
4209
+ }
4210
+ if (a === "--allow-workflows") {
4211
+ allowWorkflows = true;
4212
+ continue;
4213
+ }
4214
+ if (a === "--allow-risk-paths") {
4215
+ allowRiskPaths = true;
4216
+ continue;
4217
+ }
4218
+ if (a === "--allow-public") {
4219
+ allowPublic = true;
4220
+ continue;
4221
+ }
4222
+ if (a === "--mode") {
4223
+ const v = rest[i + 1]?.trim();
4224
+ if (v === void 0 || v === "" || v.startsWith("--")) return { kind: "usage", error: "Missing value for --mode (autonomous or pr-only)" };
4225
+ if (v !== "autonomous" && v !== "pr-only") return { kind: "usage", error: "Invalid --mode. Expected autonomous or pr-only" };
4226
+ mode = v;
4227
+ i++;
4228
+ continue;
4229
+ }
4230
+ if (a.startsWith("-")) return { kind: "usage", error: `Unknown flag for git-auth set: ${a}` };
4231
+ if (agent === void 0) {
4232
+ agent = a.trim();
4233
+ continue;
4234
+ }
4235
+ if (repo === void 0) {
4236
+ repo = a.trim();
4237
+ continue;
4238
+ }
4239
+ return {
4240
+ kind: "usage",
4241
+ error: "git-auth set does not accept the token as an argument. Pipe it on stdin: <command that prints the token> | braid git-auth set <agent> <owner/repo> --mode <mode> --token-stdin"
4242
+ };
4243
+ }
4244
+ if (!agent || !repo) return { kind: "usage", error: "Usage: braid git-auth set <agent> <owner/repo> --mode <autonomous|pr-only> --token-stdin" };
4245
+ if (!GIT_REPO_RE.test(repo)) return { kind: "usage", error: "git-auth set needs <owner/repo> (letters, digits, . _ -)" };
4246
+ if (!mode) return { kind: "usage", error: "git-auth set requires --mode <autonomous|pr-only>" };
4247
+ if (!tokenStdin) {
4248
+ return { kind: "usage", error: "git-auth set requires --token-stdin (the token is read from stdin, never from arguments)." };
4249
+ }
4250
+ const [repoOwner, repoName] = repo.split("/");
4251
+ return { kind: "git-auth-set", agent, repoOwner, repoName, mode, allowWorkflows, allowRiskPaths, allowPublic };
4252
+ }
3244
4253
  if (first === "enroll") {
3245
4254
  let inviteStdin = false;
3246
4255
  let json = false;
@@ -3302,14 +4311,14 @@ function parseCliArgs(argv) {
3302
4311
  // src/launchTui.ts
3303
4312
  import { spawn } from "child_process";
3304
4313
  import { createRequire } from "module";
3305
- import { dirname as dirname4, join as join6 } from "path";
4314
+ import { dirname as dirname6, join as join9 } from "path";
3306
4315
  function resolveTuiEntry() {
3307
4316
  try {
3308
4317
  const require2 = createRequire(import.meta.url);
3309
4318
  const pkgPath = require2.resolve("@snowyroad/braid-tui/package.json");
3310
4319
  const pkg = require2("@snowyroad/braid-tui/package.json");
3311
4320
  const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["braid-tui"];
3312
- return rel ? join6(dirname4(pkgPath), rel) : null;
4321
+ return rel ? join9(dirname6(pkgPath), rel) : null;
3313
4322
  } catch {
3314
4323
  return null;
3315
4324
  }
@@ -3321,17 +4330,17 @@ function runTui(args, env, selfCliPath) {
3321
4330
  return Promise.resolve(1);
3322
4331
  }
3323
4332
  const childEnv = { ...env, ARP_TUI_BRIDGE_CMD: `${process.execPath} ${selfCliPath}` };
3324
- return new Promise((resolve) => {
4333
+ return new Promise((resolve2) => {
3325
4334
  const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit", env: childEnv });
3326
- child.on("error", () => resolve(1));
3327
- child.on("close", (code) => resolve(code ?? 1));
4335
+ child.on("error", () => resolve2(1));
4336
+ child.on("close", (code) => resolve2(code ?? 1));
3328
4337
  });
3329
4338
  }
3330
4339
 
3331
4340
  // src/enroll.ts
3332
4341
  var ENROLL_STDIN_MAX_BYTES = 16 * 1024;
3333
- function readBoundedStdin(stream, maxBytes) {
3334
- return new Promise((resolve, reject) => {
4342
+ function readBoundedStdin(stream, maxBytes, label = "invite") {
4343
+ return new Promise((resolve2, reject) => {
3335
4344
  const chunks = [];
3336
4345
  let total = 0;
3337
4346
  let settled = false;
@@ -3347,12 +4356,12 @@ function readBoundedStdin(stream, maxBytes) {
3347
4356
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3348
4357
  total += buf.length;
3349
4358
  if (total > maxBytes) {
3350
- finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB invite limit`)));
4359
+ finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB ${label} limit`)));
3351
4360
  return;
3352
4361
  }
3353
4362
  chunks.push(buf);
3354
4363
  };
3355
- const onEnd = () => finish(() => resolve(Buffer.concat(chunks).toString("utf8").trim()));
4364
+ const onEnd = () => finish(() => resolve2(Buffer.concat(chunks).toString("utf8").trim()));
3356
4365
  const onError = (err) => finish(() => reject(err));
3357
4366
  stream.on("data", onData);
3358
4367
  stream.on("end", onEnd);
@@ -3414,7 +4423,7 @@ async function runEnroll(opts, io, env) {
3414
4423
  ...opts.model ? { model: opts.model } : {}
3415
4424
  };
3416
4425
  const file = persistOrNeedsHuman(dir, stored);
3417
- assertKeystorePerms(dir, file);
4426
+ assertKeystorePerms(dir, file, void 0, [localGitGrantPath(dir, inv.relayUrl, bundle.agentName)]);
3418
4427
  const minted = await mintAccessToken(relayHttpUrl, stored.agentKey);
3419
4428
  persistOrNeedsHuman(dir, { ...stored, agentKey: minted.agentKey });
3420
4429
  const name = sanitizeForTty(bundle.agentName);
@@ -3489,13 +4498,13 @@ function mergeFirstRunPatch(existing, patch) {
3489
4498
  }
3490
4499
  function promptYesNoDefaultYes(question, input, output) {
3491
4500
  const rl = createInterface({ input, output });
3492
- return new Promise((resolve) => {
4501
+ return new Promise((resolve2) => {
3493
4502
  let resolved = false;
3494
4503
  const done = (result) => {
3495
4504
  if (resolved) return;
3496
4505
  resolved = true;
3497
4506
  rl.close();
3498
- resolve(result);
4507
+ resolve2(result);
3499
4508
  };
3500
4509
  output.write(question);
3501
4510
  rl.on("line", (line) => {
@@ -3984,20 +4993,20 @@ async function runHandoff(opts) {
3984
4993
  );
3985
4994
  await session.handoffPause();
3986
4995
  try {
3987
- await new Promise((resolve) => {
4996
+ await new Promise((resolve2) => {
3988
4997
  const child = spawnFn(scoped.command, scoped.args, {
3989
4998
  stdio: "inherit",
3990
4999
  cwd: scoped.cwd,
3991
5000
  env: buildAcpEnv(process.env, scoped.env ?? void 0)
3992
5001
  });
3993
- child.on("exit", () => resolve());
5002
+ child.on("exit", () => resolve2());
3994
5003
  child.on("error", (e) => {
3995
5004
  out.write(
3996
5005
  `
3997
5006
  [handoff] failed to open the session: ${sanitizeForTty(String(e?.message ?? e))}
3998
5007
  `
3999
5008
  );
4000
- resolve();
5009
+ resolve2();
4001
5010
  });
4002
5011
  });
4003
5012
  } finally {
@@ -4107,12 +5116,12 @@ async function runTakeover(lc, ctx) {
4107
5116
  `);
4108
5117
  return 1;
4109
5118
  }
4110
- const outcome = await new Promise((resolve) => {
5119
+ const outcome = await new Promise((resolve2) => {
4111
5120
  let settled = false;
4112
5121
  const settle = (o) => {
4113
5122
  if (!settled) {
4114
5123
  settled = true;
4115
- resolve(o);
5124
+ resolve2(o);
4116
5125
  }
4117
5126
  };
4118
5127
  try {
@@ -4300,15 +5309,15 @@ function lineClient(sock) {
4300
5309
  },
4301
5310
  drained(timeoutMs) {
4302
5311
  if (closed) return Promise.resolve();
4303
- return new Promise((resolve) => {
5312
+ return new Promise((resolve2) => {
4304
5313
  const t = setTimeout(() => {
4305
5314
  onClose = null;
4306
- resolve();
5315
+ resolve2();
4307
5316
  }, timeoutMs);
4308
5317
  if (typeof t.unref === "function") t.unref();
4309
5318
  onClose = () => {
4310
5319
  clearTimeout(t);
4311
- resolve();
5320
+ resolve2();
4312
5321
  };
4313
5322
  });
4314
5323
  },
@@ -4434,13 +5443,13 @@ async function maybeOfferSandboxInstall(deps) {
4434
5443
  }
4435
5444
  function readlinePromptYesNo(input, output) {
4436
5445
  return import("readline").then(
4437
- ({ createInterface: createInterface3 }) => new Promise((resolve) => {
5446
+ ({ createInterface: createInterface3 }) => new Promise((resolve2) => {
4438
5447
  const rl = createInterface3({ input, output });
4439
5448
  rl.once("line", (line) => {
4440
- resolve(line.trim().toLowerCase() === "y");
5449
+ resolve2(line.trim().toLowerCase() === "y");
4441
5450
  rl.close();
4442
5451
  });
4443
- rl.once("close", () => resolve(false));
5452
+ rl.once("close", () => resolve2(false));
4444
5453
  })
4445
5454
  );
4446
5455
  }
@@ -4662,6 +5671,10 @@ async function applyEndpointGate(cfg, deps, gate, _agentPins, scopeOff = cfg.sco
4662
5671
 
4663
5672
  // src/statusVerb.ts
4664
5673
  import { homedir as homedir4 } from "os";
5674
+ function gitPosture(e, input) {
5675
+ const g = input.localGitGrant?.(e.agent) ?? null;
5676
+ return g ? { repo: `${g.repoOwner}/${g.repoName}`, mode: g.mode, source: "pat" } : null;
5677
+ }
4665
5678
  function endpointRows(e, k, input) {
4666
5679
  const derive = input.derive ?? deriveEndpoints;
4667
5680
  const derived = derive(k.provider.value, input.env, input.readTextFile);
@@ -4735,6 +5748,8 @@ function renderStatusHuman(input) {
4735
5748
  );
4736
5749
  const ipcProfile = resolveIpcProfile(e.agent.scope, void 0);
4737
5750
  lines.push(` ipcprofile: ${ipcProfile} (${e.agent.scope?.ipcProfile !== void 0 ? "file" : "default"})`);
5751
+ const git = gitPosture(e, input);
5752
+ lines.push(` git : ${git ? `${sanitizeForTty(git.repo)} (${git.mode}, ${git.source})` : "none"}`);
4738
5753
  if (e.agent.workspace) lines.push(` workspace: ${sanitizeForTty(e.agent.workspace)}`);
4739
5754
  if (e.agent.channelWorkspaces && e.agent.channelWorkspaces.length > 1) {
4740
5755
  lines.push(` channels in: ${e.agent.channelWorkspaces.map((w) => sanitizeForTty(w)).join(", ")}`);
@@ -4771,6 +5786,7 @@ function renderStatusJson(input) {
4771
5786
  }
4772
5787
  },
4773
5788
  ipcProfile: { value: ipcProfile, origin: e.agent.scope?.ipcProfile !== void 0 ? "file" : "default" },
5789
+ git: gitPosture(e, input),
4774
5790
  endpoints: endpointRows(e, k, input),
4775
5791
  // Task 7.1 (D4): null (not "personal"/omitted key) when never cached, so machine
4776
5792
  // consumers can distinguish "no data yet" from an actual value.
@@ -4782,8 +5798,8 @@ function renderStatusJson(input) {
4782
5798
  }
4783
5799
 
4784
5800
  // src/telemetry.ts
4785
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5, renameSync as renameSync3, chmodSync as chmodSync4 } from "fs";
4786
- import { dirname as dirname5, join as join7 } from "path";
5801
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, renameSync as renameSync4, chmodSync as chmodSync6 } from "fs";
5802
+ import { dirname as dirname7, join as join10 } from "path";
4787
5803
  var EVENT_PROP_ALLOWLIST = {
4788
5804
  tool_mode_selected: ["mode", "surface", "persisted"],
4789
5805
  tool_call_blocked: ["tool", "mode", "path", "reason"],
@@ -4803,11 +5819,11 @@ function isDoNotTrackSet(env) {
4803
5819
  return /^(1|true|yes)$/i.test(v.trim());
4804
5820
  }
4805
5821
  function telemetryConfigPath(dir) {
4806
- return join7(dir, "telemetry.json");
5822
+ return join10(dir, "telemetry.json");
4807
5823
  }
4808
5824
  function readTelemetryConfig(dir) {
4809
5825
  try {
4810
- const raw = readFileSync6(telemetryConfigPath(dir), "utf8");
5826
+ const raw = readFileSync9(telemetryConfigPath(dir), "utf8");
4811
5827
  const parsed = JSON.parse(raw);
4812
5828
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
4813
5829
  return parsed;
@@ -4819,12 +5835,12 @@ function readTelemetryConfig(dir) {
4819
5835
  }
4820
5836
  function writeTelemetryConfig(dir, cfg) {
4821
5837
  const file = telemetryConfigPath(dir);
4822
- mkdirSync5(dirname5(file), { recursive: true, mode: 448 });
5838
+ mkdirSync7(dirname7(file), { recursive: true, mode: 448 });
4823
5839
  const tmp = `${file}.tmp-${process.pid}`;
4824
- writeFileSync5(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
4825
- renameSync3(tmp, file);
5840
+ writeFileSync7(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
5841
+ renameSync4(tmp, file);
4826
5842
  try {
4827
- chmodSync4(file, 384);
5843
+ chmodSync6(file, 384);
4828
5844
  } catch {
4829
5845
  }
4830
5846
  }
@@ -4903,12 +5919,12 @@ function renderTelemetryStatusLines(view) {
4903
5919
  }
4904
5920
 
4905
5921
  // src/service/cli.ts
4906
- import { execFile } from "child_process";
5922
+ import { execFile as execFile2 } from "child_process";
4907
5923
  import { homedir as homedir5 } from "os";
4908
5924
 
4909
5925
  // src/service/manager.ts
4910
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, rmSync as rmSync2, existsSync as existsSync5 } from "fs";
4911
- import { join as join9 } from "path";
5926
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync9, rmSync as rmSync2, existsSync as existsSync8 } from "fs";
5927
+ import { join as join12 } from "path";
4912
5928
 
4913
5929
  // src/service/templates.ts
4914
5930
  function escapeXml(s) {
@@ -5079,18 +6095,18 @@ function schtasksXml(p) {
5079
6095
  }
5080
6096
 
5081
6097
  // src/service/win32.ts
5082
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
5083
- import { join as join8 } from "path";
6098
+ import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8, readdirSync as readdirSync4, readFileSync as readFileSync10, unlinkSync as unlinkSync5 } from "fs";
6099
+ import { join as join11 } from "path";
5084
6100
  function win32LogDir(homeDir) {
5085
- return join8(homeDir, "AppData", "Local", "braid", "logs");
6101
+ return join11(homeDir, "AppData", "Local", "braid", "logs");
5086
6102
  }
5087
6103
  function win32Manager(ctx) {
5088
- const serviceDir = join8(ctx.braidDir, "service");
6104
+ const serviceDir = join11(ctx.braidDir, "service");
5089
6105
  const task = (name) => schtasksLabel(name);
5090
6106
  const legacyTask = (name) => legacySchtasksLabel(name);
5091
- const xmlPath = (name) => join8(serviceDir, `${task(name)}.xml`);
5092
- const wrapperPath = (name) => join8(serviceDir, `${task(name)}.cmd`);
5093
- const launcherPath = (name) => join8(serviceDir, `${task(name)}.vbs`);
6107
+ const xmlPath = (name) => join11(serviceDir, `${task(name)}.xml`);
6108
+ const wrapperPath = (name) => join11(serviceDir, `${task(name)}.cmd`);
6109
+ const launcherPath = (name) => join11(serviceDir, `${task(name)}.vbs`);
5094
6110
  const st = (...args) => ctx.exec("schtasks", args);
5095
6111
  const stx = async (...args) => {
5096
6112
  const res = await st(...args);
@@ -5101,18 +6117,18 @@ function win32Manager(ctx) {
5101
6117
  };
5102
6118
  const killBridgeProcess = async (name) => {
5103
6119
  const legacyAgents = legacyTwinPath(ctx.braidDir, ["agents"]);
5104
- const agentDirs = [join8(ctx.braidDir, "agents"), ...legacyAgents ? [legacyAgents] : []];
6120
+ const agentDirs = [join11(ctx.braidDir, "agents"), ...legacyAgents ? [legacyAgents] : []];
5105
6121
  const hostDirs = [];
5106
6122
  for (const agentsDir of agentDirs) {
5107
6123
  try {
5108
- for (const host of readdirSync4(agentsDir)) hostDirs.push(join8(agentsDir, host));
6124
+ for (const host of readdirSync4(agentsDir)) hostDirs.push(join11(agentsDir, host));
5109
6125
  } catch {
5110
6126
  }
5111
6127
  }
5112
6128
  for (const hostDir of hostDirs) {
5113
6129
  let pid = NaN;
5114
6130
  try {
5115
- pid = Number(readFileSync7(join8(hostDir, `${name}.json.lock`), "utf8").trim());
6131
+ pid = Number(readFileSync10(join11(hostDir, `${name}.json.lock`), "utf8").trim());
5116
6132
  } catch {
5117
6133
  continue;
5118
6134
  }
@@ -5128,15 +6144,15 @@ function win32Manager(ctx) {
5128
6144
  }
5129
6145
  };
5130
6146
  const write = (name) => {
5131
- mkdirSync6(serviceDir, { recursive: true });
6147
+ mkdirSync8(serviceDir, { recursive: true });
5132
6148
  const entryPath = pinnedEntryPath({ braidDir: ctx.braidDir, platform: "win32" });
5133
6149
  const cmd = schtasksWrapperCmd({ name, nodePath: ctx.nodePath, entryPath });
5134
- writeFileSync6(wrapperPath(name), cmd);
6150
+ writeFileSync8(wrapperPath(name), cmd);
5135
6151
  const vbs = schtasksLauncherVbs({ wrapperPath: wrapperPath(name) });
5136
- writeFileSync6(launcherPath(name), vbs);
6152
+ writeFileSync8(launcherPath(name), vbs);
5137
6153
  const xml = schtasksXml({ name, launcherPath: launcherPath(name) });
5138
6154
  const path2 = xmlPath(name);
5139
- writeFileSync6(path2, Buffer.concat([Buffer.from([255, 254]), Buffer.from(xml, "utf16le")]));
6155
+ writeFileSync8(path2, Buffer.concat([Buffer.from([255, 254]), Buffer.from(xml, "utf16le")]));
5140
6156
  return path2;
5141
6157
  };
5142
6158
  return {
@@ -5156,13 +6172,13 @@ function win32Manager(ctx) {
5156
6172
  await st("/delete", "/tn", task(name), "/f");
5157
6173
  await st("/delete", "/tn", legacyTask(name), "/f");
5158
6174
  try {
5159
- unlinkSync3(join8(runDir(ctx.braidDir), `${name}.token`));
6175
+ unlinkSync5(join11(runDir(ctx.braidDir), `${name}.token`));
5160
6176
  } catch {
5161
6177
  }
5162
6178
  const legacyToken = legacyTwinPath(ctx.braidDir, ["run", `${name}.token`]);
5163
6179
  if (legacyToken) {
5164
6180
  try {
5165
- unlinkSync3(legacyToken);
6181
+ unlinkSync5(legacyToken);
5166
6182
  } catch {
5167
6183
  }
5168
6184
  }
@@ -5194,7 +6210,7 @@ function win32Manager(ctx) {
5194
6210
  return { installed, loaded, running };
5195
6211
  },
5196
6212
  logsPath(name) {
5197
- return join8(win32LogDir(ctx.homeDir), `${name}.err.log`);
6213
+ return join11(win32LogDir(ctx.homeDir), `${name}.err.log`);
5198
6214
  }
5199
6215
  };
5200
6216
  }
@@ -5208,7 +6224,7 @@ var NotSupportedError = class extends Error {
5208
6224
  };
5209
6225
  function makeServiceManager(platform, exec, deps = {}) {
5210
6226
  const homeDir = deps.homeDir ?? process.env.HOME ?? "";
5211
- const braidDir = deps.braidDir ?? join9(homeDir, ".braid");
6227
+ const braidDir = deps.braidDir ?? join12(homeDir, ".braid");
5212
6228
  const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
5213
6229
  const nodePath = deps.nodePath ?? process.execPath;
5214
6230
  const entryPath = pinnedEntryPath({ braidDir });
@@ -5218,33 +6234,33 @@ function makeServiceManager(platform, exec, deps = {}) {
5218
6234
  return notSupportedManager(platform);
5219
6235
  }
5220
6236
  function notSupportedManager(platform) {
5221
- const fail = () => {
6237
+ const fail2 = () => {
5222
6238
  throw new NotSupportedError(`service management is not supported on platform "${platform}"`);
5223
6239
  };
5224
6240
  return {
5225
- install: async () => fail(),
5226
- uninstall: async () => fail(),
5227
- start: async () => fail(),
5228
- stop: async () => fail(),
5229
- restart: async () => fail(),
5230
- status: async () => fail(),
5231
- logsPath: () => fail(),
5232
- templatePath: () => fail()
6241
+ install: async () => fail2(),
6242
+ uninstall: async () => fail2(),
6243
+ start: async () => fail2(),
6244
+ stop: async () => fail2(),
6245
+ restart: async () => fail2(),
6246
+ status: async () => fail2(),
6247
+ logsPath: () => fail2(),
6248
+ templatePath: () => fail2()
5233
6249
  };
5234
6250
  }
5235
6251
  function darwinManager(ctx) {
5236
- const launchAgentsDir = join9(ctx.homeDir, "Library", "LaunchAgents");
5237
- const logDir = join9(ctx.homeDir, "Library", "Logs", "braid");
5238
- const legacyLogDir = join9(ctx.homeDir, "Library", "Logs", "arp-bridge");
5239
- const plistPath = (name) => join9(launchAgentsDir, `${launchdLabel(name)}.plist`);
5240
- const legacyPlistPath = (name) => join9(launchAgentsDir, `${legacyLaunchdLabel(name)}.plist`);
5241
- const legacyInstalled = (name) => !existsSync5(plistPath(name)) && existsSync5(legacyPlistPath(name));
6252
+ const launchAgentsDir = join12(ctx.homeDir, "Library", "LaunchAgents");
6253
+ const logDir = join12(ctx.homeDir, "Library", "Logs", "braid");
6254
+ const legacyLogDir = join12(ctx.homeDir, "Library", "Logs", "arp-bridge");
6255
+ const plistPath = (name) => join12(launchAgentsDir, `${launchdLabel(name)}.plist`);
6256
+ const legacyPlistPath = (name) => join12(launchAgentsDir, `${legacyLaunchdLabel(name)}.plist`);
6257
+ const legacyInstalled = (name) => !existsSync8(plistPath(name)) && existsSync8(legacyPlistPath(name));
5242
6258
  const domainTarget = `gui/${ctx.uid}`;
5243
6259
  const serviceTarget = (name) => `${domainTarget}/${legacyInstalled(name) ? legacyLaunchdLabel(name) : launchdLabel(name)}`;
5244
6260
  const write = (name) => {
5245
- mkdirSync7(launchAgentsDir, { recursive: true });
5246
- mkdirSync7(logDir, { recursive: true });
5247
- writeFileSync7(
6261
+ mkdirSync9(launchAgentsDir, { recursive: true });
6262
+ mkdirSync9(logDir, { recursive: true });
6263
+ writeFileSync9(
5248
6264
  plistPath(name),
5249
6265
  launchdPlist({ name, nodePath: ctx.nodePath, entryPath: ctx.entryPath, logDir }),
5250
6266
  { mode: 420 }
@@ -5275,7 +6291,7 @@ function darwinManager(ctx) {
5275
6291
  }
5276
6292
  },
5277
6293
  async start(name) {
5278
- if (!existsSync5(plistPath(name)) && !existsSync5(legacyPlistPath(name))) write(name);
6294
+ if (!existsSync8(plistPath(name)) && !existsSync8(legacyPlistPath(name))) write(name);
5279
6295
  await ctx.exec("launchctl", [
5280
6296
  "bootstrap",
5281
6297
  domainTarget,
@@ -5289,7 +6305,7 @@ function darwinManager(ctx) {
5289
6305
  await ctx.exec("launchctl", ["kickstart", "-k", serviceTarget(name)]);
5290
6306
  },
5291
6307
  async status(name) {
5292
- const installed = existsSync5(plistPath(name)) || existsSync5(legacyPlistPath(name));
6308
+ const installed = existsSync8(plistPath(name)) || existsSync8(legacyPlistPath(name));
5293
6309
  if (!installed) return { installed: false, loaded: false, running: false };
5294
6310
  const res = await ctx.exec("launchctl", ["print", serviceTarget(name)]);
5295
6311
  const loaded = res.code === 0;
@@ -5298,27 +6314,27 @@ function darwinManager(ctx) {
5298
6314
  installed,
5299
6315
  loaded,
5300
6316
  running,
5301
- staleNodePath: !existsSync5(ctx.nodePath)
6317
+ staleNodePath: !existsSync8(ctx.nodePath)
5302
6318
  };
5303
6319
  },
5304
6320
  logsPath(name) {
5305
- return join9(legacyInstalled(name) ? legacyLogDir : logDir, `${name}.err.log`);
6321
+ return join12(legacyInstalled(name) ? legacyLogDir : logDir, `${name}.err.log`);
5306
6322
  }
5307
6323
  };
5308
6324
  }
5309
6325
  function linuxManager(ctx) {
5310
- const unitDir = join9(ctx.homeDir, ".config", "systemd", "user");
6326
+ const unitDir = join12(ctx.homeDir, ".config", "systemd", "user");
5311
6327
  const newUnitName = (name) => `braid-${name}.service`;
5312
6328
  const legacyUnitName = (name) => `arp-bridge-${name}.service`;
5313
- const newUnitPath = (name) => join9(unitDir, newUnitName(name));
5314
- const legacyUnitPath = (name) => join9(unitDir, legacyUnitName(name));
5315
- const legacyInstalled = (name) => !existsSync5(newUnitPath(name)) && existsSync5(legacyUnitPath(name));
6329
+ const newUnitPath = (name) => join12(unitDir, newUnitName(name));
6330
+ const legacyUnitPath = (name) => join12(unitDir, legacyUnitName(name));
6331
+ const legacyInstalled = (name) => !existsSync8(newUnitPath(name)) && existsSync8(legacyUnitPath(name));
5316
6332
  const unitName = (name) => legacyInstalled(name) ? legacyUnitName(name) : newUnitName(name);
5317
6333
  const unitPath = (name) => legacyInstalled(name) ? legacyUnitPath(name) : newUnitPath(name);
5318
6334
  const sc = (...args) => ctx.exec("systemctl", ["--user", ...args]);
5319
6335
  const write = (name) => {
5320
- mkdirSync7(unitDir, { recursive: true });
5321
- writeFileSync7(
6336
+ mkdirSync9(unitDir, { recursive: true });
6337
+ writeFileSync9(
5322
6338
  newUnitPath(name),
5323
6339
  systemdUnit({ name, nodePath: ctx.nodePath, entryPath: ctx.entryPath }),
5324
6340
  { mode: 420 }
@@ -5329,7 +6345,7 @@ function linuxManager(ctx) {
5329
6345
  async install(name) {
5330
6346
  write(name);
5331
6347
  await sc("daemon-reload");
5332
- if (existsSync5(legacyUnitPath(name))) {
6348
+ if (existsSync8(legacyUnitPath(name))) {
5333
6349
  await sc("disable", "--now", legacyUnitName(name));
5334
6350
  try {
5335
6351
  rmSync2(legacyUnitPath(name), { force: true });
@@ -5341,7 +6357,7 @@ function linuxManager(ctx) {
5341
6357
  },
5342
6358
  async uninstall(name) {
5343
6359
  await sc("disable", "--now", newUnitName(name));
5344
- if (existsSync5(legacyUnitPath(name))) {
6360
+ if (existsSync8(legacyUnitPath(name))) {
5345
6361
  await sc("disable", "--now", legacyUnitName(name));
5346
6362
  }
5347
6363
  try {
@@ -5355,7 +6371,7 @@ function linuxManager(ctx) {
5355
6371
  await sc("daemon-reload");
5356
6372
  },
5357
6373
  async start(name) {
5358
- if (!existsSync5(unitPath(name))) {
6374
+ if (!existsSync8(unitPath(name))) {
5359
6375
  write(name);
5360
6376
  await sc("daemon-reload");
5361
6377
  }
@@ -5368,7 +6384,7 @@ function linuxManager(ctx) {
5368
6384
  await sc("restart", unitName(name));
5369
6385
  },
5370
6386
  async status(name) {
5371
- const installed = existsSync5(unitPath(name));
6387
+ const installed = existsSync8(unitPath(name));
5372
6388
  if (!installed) return { installed: false, loaded: false, running: false };
5373
6389
  const active = await sc("is-active", unitName(name));
5374
6390
  const enabled = await sc("is-enabled", unitName(name));
@@ -5378,7 +6394,7 @@ function linuxManager(ctx) {
5378
6394
  installed,
5379
6395
  loaded,
5380
6396
  running,
5381
- staleNodePath: !existsSync5(ctx.nodePath)
6397
+ staleNodePath: !existsSync8(ctx.nodePath)
5382
6398
  };
5383
6399
  },
5384
6400
  logsPath(name) {
@@ -5388,10 +6404,10 @@ function linuxManager(ctx) {
5388
6404
  }
5389
6405
 
5390
6406
  // src/service/cli.ts
5391
- var realExec = (cmd, args) => new Promise((resolve) => {
5392
- execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
6407
+ var realExec = (cmd, args) => new Promise((resolve2) => {
6408
+ execFile2(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
5393
6409
  const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
5394
- resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
6410
+ resolve2({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
5395
6411
  });
5396
6412
  });
5397
6413
  function resolveAgentName(explicit) {
@@ -5592,17 +6608,17 @@ function printStatusHuman(name, st, logsPath) {
5592
6608
  }
5593
6609
 
5594
6610
  // src/service/serviceLog.ts
5595
- import { createWriteStream, mkdirSync as mkdirSync8 } from "fs";
5596
- import { dirname as dirname6, join as join10 } from "path";
6611
+ import { createWriteStream, mkdirSync as mkdirSync10 } from "fs";
6612
+ import { dirname as dirname8, join as join13 } from "path";
5597
6613
 
5598
6614
  // src/service/logRotation.ts
5599
6615
  import {
5600
- existsSync as existsSync6,
5601
- statSync as statSync2,
5602
- renameSync as renameSync4,
6616
+ existsSync as existsSync9,
6617
+ statSync as statSync3,
6618
+ renameSync as renameSync5,
5603
6619
  rmSync as rmSync3,
5604
- readFileSync as readFileSync8,
5605
- writeFileSync as writeFileSync8
6620
+ readFileSync as readFileSync11,
6621
+ writeFileSync as writeFileSync10
5606
6622
  } from "fs";
5607
6623
  import { gzipSync } from "zlib";
5608
6624
  var DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
@@ -5611,33 +6627,33 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
5611
6627
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
5612
6628
  const keepCount = opts.keepCount ?? DEFAULT_KEEP_COUNT;
5613
6629
  try {
5614
- if (!existsSync6(logPath)) return;
6630
+ if (!existsSync9(logPath)) return;
5615
6631
  let size;
5616
6632
  try {
5617
- size = statSync2(logPath).size;
6633
+ size = statSync3(logPath).size;
5618
6634
  } catch {
5619
6635
  return;
5620
6636
  }
5621
6637
  if (size <= maxBytes) return;
5622
6638
  const gz = (n) => `${logPath}.${n}.gz`;
5623
6639
  try {
5624
- if (existsSync6(gz(keepCount))) rmSync3(gz(keepCount), { force: true });
6640
+ if (existsSync9(gz(keepCount))) rmSync3(gz(keepCount), { force: true });
5625
6641
  } catch {
5626
6642
  }
5627
6643
  for (let n = keepCount - 1; n >= 1; n--) {
5628
6644
  try {
5629
- if (existsSync6(gz(n))) renameSync4(gz(n), gz(n + 1));
6645
+ if (existsSync9(gz(n))) renameSync5(gz(n), gz(n + 1));
5630
6646
  } catch {
5631
6647
  }
5632
6648
  }
5633
6649
  try {
5634
- const raw = readFileSync8(logPath);
5635
- writeFileSync8(gz(1), gzipSync(raw));
6650
+ const raw = readFileSync11(logPath);
6651
+ writeFileSync10(gz(1), gzipSync(raw));
5636
6652
  } catch {
5637
6653
  return;
5638
6654
  }
5639
6655
  try {
5640
- writeFileSync8(logPath, "");
6656
+ writeFileSync10(logPath, "");
5641
6657
  } catch {
5642
6658
  }
5643
6659
  } catch {
@@ -5646,16 +6662,16 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
5646
6662
 
5647
6663
  // src/service/serviceLog.ts
5648
6664
  function darwinLogDir(homeDir) {
5649
- return join10(homeDir, "Library", "Logs", "braid");
6665
+ return join13(homeDir, "Library", "Logs", "braid");
5650
6666
  }
5651
6667
  function serviceLogTargets(name, platform, homeDir) {
5652
6668
  if (platform === "darwin") {
5653
6669
  const d = darwinLogDir(homeDir);
5654
- return [join10(d, `${name}.out.log`), join10(d, `${name}.err.log`)];
6670
+ return [join13(d, `${name}.out.log`), join13(d, `${name}.err.log`)];
5655
6671
  }
5656
6672
  if (platform === "win32") {
5657
6673
  const d = win32LogDir(homeDir);
5658
- return [join10(d, `${name}.out.log`), join10(d, `${name}.err.log`)];
6674
+ return [join13(d, `${name}.out.log`), join13(d, `${name}.err.log`)];
5659
6675
  }
5660
6676
  return [];
5661
6677
  }
@@ -5705,7 +6721,7 @@ function initServiceLogging(opts) {
5705
6721
  };
5706
6722
  }
5707
6723
  function defaultTeeStream(path2) {
5708
- mkdirSync8(dirname6(path2), { recursive: true });
6724
+ mkdirSync10(dirname8(path2), { recursive: true });
5709
6725
  const stream = createWriteStream(path2, { flags: "a" });
5710
6726
  stream.on("error", () => {
5711
6727
  });
@@ -5845,12 +6861,13 @@ function printStatus(agentName, json) {
5845
6861
  }
5846
6862
  const readTextFile = (p) => {
5847
6863
  try {
5848
- return readFileSync9(p, "utf8");
6864
+ return readFileSync12(p, "utf8");
5849
6865
  } catch {
5850
6866
  return null;
5851
6867
  }
5852
6868
  };
5853
- const input = { entries, env: process.env, readTextFile };
6869
+ const localGitGrant = (a) => loadLocalGitGrant(dir, a.relayUrl, a.agentName);
6870
+ const input = { entries, env: process.env, readTextFile, localGitGrant };
5854
6871
  if (json) {
5855
6872
  console.log(renderStatusJson(input));
5856
6873
  return;
@@ -5913,19 +6930,84 @@ function setIpcProfile(profile, agentName) {
5913
6930
  );
5914
6931
  }
5915
6932
  }
6933
+ async function gitAuthSet(inv) {
6934
+ const dir = configDir(process.env);
6935
+ const entry = loadAgent(dir, inv.agent, "Run: braid list").agent;
6936
+ if (process.stdin.isTTY === true) {
6937
+ console.error(
6938
+ "[braid] git-auth set reads the token from stdin and nothing is piped in. Run it as: <command that prints the token> | braid git-auth set <agent> <owner/repo> --mode <mode> --token-stdin"
6939
+ );
6940
+ return 1;
6941
+ }
6942
+ let token;
6943
+ try {
6944
+ token = (await readBoundedStdin(process.stdin, 4096, "token")).trim();
6945
+ } catch (err) {
6946
+ console.error(`[braid] git-auth set failed: ${sanitizeForTty(err instanceof Error ? err.message : String(err))}`);
6947
+ return 1;
6948
+ }
6949
+ if (token === "") {
6950
+ console.error("[braid] git-auth set received an empty token on stdin. Pipe the token in and retry.");
6951
+ return 1;
6952
+ }
6953
+ saveLocalGitGrant(dir, entry.relayUrl, entry.agentName, {
6954
+ source: "pat",
6955
+ repoOwner: inv.repoOwner,
6956
+ repoName: inv.repoName,
6957
+ mode: inv.mode,
6958
+ allowWorkflows: inv.allowWorkflows,
6959
+ allowRiskPaths: inv.allowRiskPaths,
6960
+ allowPublic: inv.allowPublic,
6961
+ token
6962
+ });
6963
+ console.log(
6964
+ `[braid] ${sanitizeForTty(entry.agentName)}: git access set for ${inv.repoOwner}/${inv.repoName} (${inv.mode}, local token). Restart the bridge for the change to take effect.`
6965
+ );
6966
+ return 0;
6967
+ }
6968
+ function gitAuthShow(agentName) {
6969
+ const dir = configDir(process.env);
6970
+ const entry = loadAgent(dir, agentName, "Run: braid list").agent;
6971
+ const name = sanitizeForTty(entry.agentName);
6972
+ const g = loadLocalGitGrant(dir, entry.relayUrl, entry.agentName);
6973
+ if (!g) {
6974
+ console.log(`[braid] ${name}: no local git grant.`);
6975
+ return;
6976
+ }
6977
+ console.log(`[braid] ${name}: local git grant`);
6978
+ console.log(` repo : ${sanitizeForTty(`${g.repoOwner}/${g.repoName}`)}`);
6979
+ console.log(` mode : ${g.mode}`);
6980
+ console.log(` allowWorkflows : ${g.allowWorkflows}`);
6981
+ console.log(` allowRiskPaths : ${g.allowRiskPaths}`);
6982
+ console.log(` allowPublic : ${g.allowPublic}`);
6983
+ console.log(" source: pat");
6984
+ }
6985
+ function gitAuthClear(agentName) {
6986
+ const dir = configDir(process.env);
6987
+ const entry = loadAgent(dir, agentName, "Run: braid list").agent;
6988
+ const name = sanitizeForTty(entry.agentName);
6989
+ if (clearLocalGitGrant(dir, entry.relayUrl, entry.agentName)) {
6990
+ console.log(`[braid] ${name}: local git grant removed. Restart the bridge for the change to take effect.`);
6991
+ } else {
6992
+ console.log(`[braid] ${name}: nothing to clear, no local git grant was set.`);
6993
+ }
6994
+ }
5916
6995
  function printScope(agentName) {
5917
6996
  const dir = configDir(process.env);
5918
6997
  let scope;
5919
6998
  let pinnedEndpoints;
6999
+ let resolved;
5920
7000
  if (agentName !== void 0) {
5921
7001
  const entry = loadAgent(dir, agentName, "Run: braid scope <name>").agent;
5922
7002
  scope = entry.scope;
5923
7003
  pinnedEndpoints = entry.pinnedEndpoints;
7004
+ resolved = entry;
5924
7005
  } else {
5925
7006
  try {
5926
7007
  const entry = loadAgent(dir).agent;
5927
7008
  scope = entry.scope;
5928
7009
  pinnedEndpoints = entry.pinnedEndpoints;
7010
+ resolved = entry;
5929
7011
  } catch {
5930
7012
  }
5931
7013
  }
@@ -5934,9 +7016,15 @@ function printScope(agentName) {
5934
7016
  for (const line of lines) {
5935
7017
  console.log(line);
5936
7018
  }
7019
+ if (resolved !== void 0) {
7020
+ const g = loadLocalGitGrant(dir, resolved.relayUrl, resolved.agentName);
7021
+ console.log(
7022
+ g ? `[scope] git access: ${sanitizeForTty(`${g.repoOwner}/${g.repoName}`)} (${g.mode}, pat)` : "[scope] git access: none locally (app grants, if any, are shown by the web app)"
7023
+ );
7024
+ }
5937
7025
  const readTextFile = (p) => {
5938
7026
  try {
5939
- return readFileSync9(p, "utf8");
7027
+ return readFileSync12(p, "utf8");
5940
7028
  } catch {
5941
7029
  return null;
5942
7030
  }
@@ -5976,7 +7064,7 @@ function scopeAllow(agentName, host) {
5976
7064
  const { agent: provider } = resolveAgentSelection(process.env);
5977
7065
  const readTextFile = (p) => {
5978
7066
  try {
5979
- return readFileSync9(p, "utf8");
7067
+ return readFileSync12(p, "utf8");
5980
7068
  } catch {
5981
7069
  return null;
5982
7070
  }
@@ -6091,6 +7179,17 @@ async function main() {
6091
7179
  scopeAllowCodeHosts(invocation.agent);
6092
7180
  return;
6093
7181
  }
7182
+ if (invocation.kind === "git-auth-set") {
7183
+ process.exit(await gitAuthSet(invocation));
7184
+ }
7185
+ if (invocation.kind === "git-auth-show") {
7186
+ gitAuthShow(invocation.agent);
7187
+ return;
7188
+ }
7189
+ if (invocation.kind === "git-auth-clear") {
7190
+ gitAuthClear(invocation.agent);
7191
+ return;
7192
+ }
6094
7193
  if (invocation.kind === "telemetry") {
6095
7194
  runTelemetryVerb(invocation.verb);
6096
7195
  return;
@@ -6241,7 +7340,7 @@ async function main() {
6241
7340
  } catch {
6242
7341
  }
6243
7342
  }
6244
- assertKeystorePerms(dir, file);
7343
+ assertKeystorePerms(dir, file, void 0, cfg.agentName ? [localGitGrantPath(dir, cfg.relayWsUrl, cfg.agentName)] : []);
6245
7344
  }
6246
7345
  console.log(banner("braid", "STARTING", startingLines(cfg, {
6247
7346
  ...cfg.agentMode === "acp" && cfg.agent === "claude-code" ? { claudeBinary: describeClaudeBinary(resolveClaudeBinary()) } : {}
@@ -6262,7 +7361,7 @@ async function main() {
6262
7361
  const epScopeOff = process.env.BRAID_SCOPE === "off" || cfg.scope?.disabled === true;
6263
7362
  const readTextFile = (p) => {
6264
7363
  try {
6265
- return readFileSync9(p, "utf8");
7364
+ return readFileSync12(p, "utf8");
6266
7365
  } catch {
6267
7366
  return null;
6268
7367
  }
@@ -6292,7 +7391,7 @@ async function main() {
6292
7391
  cancelled = true;
6293
7392
  rl.close();
6294
7393
  });
6295
- const eofGuard = new Promise((resolve) => rl.once("close", () => resolve(false)));
7394
+ const eofGuard = new Promise((resolve2) => rl.once("close", () => resolve2(false)));
6296
7395
  try {
6297
7396
  const answer = await Promise.race([
6298
7397
  rl.question(prompt).then((a) => a.trim().toLowerCase() === "y"),