@snowyroad/braid 0.98.0 → 0.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2613,6 +2613,17 @@ function oneLine(s, max = 300) {
2613
2613
  function errText(e) {
2614
2614
  return oneLine(e instanceof Error ? e.message : String(e));
2615
2615
  }
2616
+ var GH_OWNER_RE = /^[A-Za-z0-9-]{1,100}$/;
2617
+ var GH_REPO_RE = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
2618
+ function parseGitHubOrigin(url) {
2619
+ const s = url.trim();
2620
+ const m = /^https:\/\/(?:[^@/\s]+@)?github\.com\/(.+)$/i.exec(s) ?? /^ssh:\/\/(?:[^@/\s]+@)?github\.com(?::22)?\/(.+)$/i.exec(s) ?? /^(?:[^@/\s:]+@)?github\.com:(?!\/)(.+)$/i.exec(s);
2621
+ if (!m) return null;
2622
+ const parts = m[1].replace(/\/$/, "").replace(/\.git$/i, "").split("/");
2623
+ if (parts.length !== 2) return null;
2624
+ const [owner, name] = parts;
2625
+ return GH_OWNER_RE.test(owner) && GH_REPO_RE.test(name) ? { owner, name } : null;
2626
+ }
2616
2627
  function shellQuote(p) {
2617
2628
  return `'${p.replace(/'/g, `'\\''`)}'`;
2618
2629
  }
@@ -2625,6 +2636,9 @@ function safeSegment(s) {
2625
2636
  function logSafe(s) {
2626
2637
  return s.replace(/[^A-Za-z0-9._\/-]/g, "?").slice(0, 200) || "-";
2627
2638
  }
2639
+ function repoKey(g) {
2640
+ return `${g.repoOwner}/${g.repoName}`.toLowerCase();
2641
+ }
2628
2642
  function isAutonomous(g) {
2629
2643
  return g.mode === "autonomous";
2630
2644
  }
@@ -2677,7 +2691,7 @@ var GitGateway = class {
2677
2691
  async statusInner(channelId, args) {
2678
2692
  const gate = this.gateChecks(channelId);
2679
2693
  if (gate) return gate;
2680
- const grant = await this.singleGrant();
2694
+ const grant = await this.resolveGrant(channelId, args.path, true);
2681
2695
  if (!grant.ok) return grant;
2682
2696
  const g = grant.grant;
2683
2697
  const wt = this.worktree(channelId, args.path);
@@ -2717,12 +2731,12 @@ var GitGateway = class {
2717
2731
  `Worktree ${wt.path}: branch ${branch ?? "unknown"}, HEAD ${head ?? "none"}.`,
2718
2732
  tracking
2719
2733
  ];
2720
- return { ok: true, text: lines.join("\n"), data: { repo, mode, branch, head, ahead, behind } };
2734
+ return { ok: true, text: lines.join("\n"), data: { repo, mode, source: g.source, branch, head, ahead, behind } };
2721
2735
  }
2722
2736
  async fetchInner(channelId, args) {
2723
2737
  const gate = this.gateChecks(channelId);
2724
2738
  if (gate) return gate;
2725
- const grant = await this.singleGrant();
2739
+ const grant = await this.resolveGrant(channelId, args.path, true);
2726
2740
  if (!grant.ok) return grant;
2727
2741
  const g = grant.grant;
2728
2742
  const mirror = this.mirrorPath(g);
@@ -2767,7 +2781,7 @@ ${shown}${more}`,
2767
2781
  async pushInner(channelId, branch, args, out) {
2768
2782
  const gate = this.gateChecks(channelId);
2769
2783
  if (gate) return gate;
2770
- const grant = await this.singleGrant();
2784
+ const grant = await this.resolveGrant(channelId, args.path, true);
2771
2785
  if (!grant.ok) return grant;
2772
2786
  const listed = grant.grant;
2773
2787
  const repo = `${listed.repoOwner}/${listed.repoName}`;
@@ -2840,7 +2854,7 @@ Compare: https://github.com/${repo}/compare/${encodeURIComponent(branch)}`,
2840
2854
  async openPrInner(channelId, args) {
2841
2855
  const gate = this.gateChecks(channelId);
2842
2856
  if (gate) return gate;
2843
- const grant = await this.singleGrant();
2857
+ const grant = await this.resolveGrant(channelId, args.path, false);
2844
2858
  if (!grant.ok) return grant;
2845
2859
  const branch = String(args.branch ?? "");
2846
2860
  const badHead = await this.checkBranch(branch);
@@ -2876,7 +2890,7 @@ Compare: https://github.com/${repo}/compare/${encodeURIComponent(branch)}`,
2876
2890
  async mergePrInner(channelId, args) {
2877
2891
  const gate = this.gateChecks(channelId);
2878
2892
  if (gate) return gate;
2879
- const grant = await this.singleGrant();
2893
+ const grant = await this.resolveGrant(channelId, void 0, false);
2880
2894
  if (!grant.ok) return grant;
2881
2895
  const num = Number(args.number);
2882
2896
  if (!Number.isInteger(num) || num <= 0) return fail(400, "invalid_number", "Pass the pull request number as a positive integer.");
@@ -2963,17 +2977,70 @@ Compare: https://github.com/${repo}/compare/${encodeURIComponent(branch)}`,
2963
2977
  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
2978
  return null;
2965
2979
  }
2966
- /** D7: one grant per agent for now; the repo is never chosen by the model. */
2967
- async singleGrant() {
2980
+ /** D11 + R15: exactly one grant (server or PAT) is used as is, whatever the worktree's
2981
+ * origin (a fork origin keeps working, as in 0.98.0). When every grant is for the same
2982
+ * repo (case-insensitive owner/name, e.g. a server grant plus a local PAT), the repo is
2983
+ * unambiguous: no origin lookup, and grantForRepo picks the server grant over the PAT.
2984
+ * With grants for two or more distinct repos, "the repo" is the
2985
+ * worktree's GitHub origin and its grant is the server grant when one exists, else the
2986
+ * local PAT (resolver.grantForRepo); an origin matching no grant is no_grant_for_repo and
2987
+ * no usable origin is no_grant_for_worktree. The grant is still the only source of the
2988
+ * push/fetch target (D8.1): the origin only selects among grants the owner already made,
2989
+ * never names a destination. Ops that do not need a worktree (open/merge PR without a
2990
+ * path) treat an unusable cwd as "no origin". */
2991
+ async resolveGrant(channelId, path2, needWorktree) {
2968
2992
  let grants;
2969
2993
  try {
2970
2994
  grants = await this.deps.resolver.listGrants();
2971
2995
  } catch (e) {
2972
2996
  return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
2973
2997
  }
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] };
2998
+ const where = `Agents > ${this.deps.agentName} > Git access`;
2999
+ if (grants.length === 0) return fail(404, "no_grant", `No git grant for this agent. Your owner can add one under ${where}.`);
3000
+ if (grants.length === 1) return { ok: true, grant: grants[0] };
3001
+ if (new Set(grants.map(repoKey)).size === 1) {
3002
+ const only = grants[0];
3003
+ let g2;
3004
+ try {
3005
+ g2 = await this.deps.resolver.grantForRepo(only.repoOwner, only.repoName);
3006
+ } catch (e) {
3007
+ return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
3008
+ }
3009
+ return { ok: true, grant: g2 ?? only };
3010
+ }
3011
+ const wt = this.worktree(channelId, path2);
3012
+ if (!wt.ok && (needWorktree || path2 !== void 0)) return wt;
3013
+ const origin = wt.ok ? await this.worktreeOrigin(wt) : null;
3014
+ if (origin && !origin.ok) return origin;
3015
+ const repo = origin?.repo ?? null;
3016
+ if (!repo) {
3017
+ const example = `${grants[0].repoOwner}/${grants[0].repoName}`;
3018
+ return fail(404, "no_grant_for_worktree", `This worktree has no GitHub origin remote and the agent has ${grants.length} git grants (${this.grantList(grants)}). Add the granted repository as origin (git remote add origin https://github.com/${example}.git, using the repository you are working on) so the gateway knows which grant applies.`);
3019
+ }
3020
+ let g;
3021
+ try {
3022
+ g = await this.deps.resolver.grantForRepo(repo.owner, repo.name);
3023
+ } catch (e) {
3024
+ return fail(502, "grants_unavailable", `Could not load this agent's git grants (${errText(e)}). Try again in a minute.`);
3025
+ }
3026
+ if (!g) return fail(404, "no_grant_for_repo", `This worktree's origin is ${repo.owner}/${repo.name}, and the agent has no git grant for that repository (its grants: ${this.grantList(grants)}). Your owner can add ${repo.owner}/${repo.name} under ${where}.`);
3027
+ return { ok: true, grant: g };
3028
+ }
3029
+ grantList(grants) {
3030
+ const byKey = /* @__PURE__ */ new Map();
3031
+ for (const x of grants) if (!byKey.has(repoKey(x))) byKey.set(repoKey(x), `${x.repoOwner}/${x.repoName}`);
3032
+ const names = [...byKey.values()];
3033
+ return names.slice(0, 10).join(", ") + (names.length > 10 ? `, and ${names.length - 10} more` : "");
3034
+ }
3035
+ /** R15: remote.origin.url of the validated worktree, read with the hardened runner while
3036
+ * the worktree identity is held; null repo = no usable (GitHub) origin. */
3037
+ async worktreeOrigin(wt) {
3038
+ const changed = fail(400, "invalid_path", "The worktree changed while its origin remote was being read (its .git was replaced or gained alternates or a config include). Make sure nothing else is modifying the repository, then try again.");
3039
+ const id = this.identity(wt);
3040
+ if (!id) return changed;
3041
+ const r = await this.git(["config", "--get", "remote.origin.url"], { gitDir: wt.gitDir, cwd: wt.path });
3042
+ if (!this.sameIdentity(wt, id)) return changed;
3043
+ return { ok: true, repo: r.code === 0 ? parseGitHubOrigin(r.stdout) : null };
2977
3044
  }
2978
3045
  /** Credential plus the effective policy. When the listed grant is LOOSER than the
2979
3046
  * policy the cached mint carries (the owner loosened the grant after the mint), the
@@ -3323,11 +3390,13 @@ var GrantResolver = class {
3323
3390
  }
3324
3391
  return out;
3325
3392
  }
3393
+ /** D11: a server (app) grant for the repo wins; otherwise the local PAT grant.
3394
+ * GitHub owner and repo names are case-insensitive. */
3326
3395
  async grantForRepo(owner, name) {
3327
3396
  const o = owner.toLowerCase();
3328
3397
  const n = name.toLowerCase();
3329
- const all = await this.listGrants();
3330
- return all.find((g) => g.repoOwner.toLowerCase() === o && g.repoName.toLowerCase() === n) ?? null;
3398
+ const matches = (await this.listGrants()).filter((g) => g.repoOwner.toLowerCase() === o && g.repoName.toLowerCase() === n);
3399
+ return matches.find((g) => g.source === "app") ?? matches.find((g) => g.source === "pat") ?? null;
3331
3400
  }
3332
3401
  async credentialFor(grant) {
3333
3402
  if (grant.source === "pat") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowyroad/braid",
3
- "version": "0.98.0",
3
+ "version": "0.98.1",
4
4
  "description": "Braid: connect your own coding agent (Claude Code, Codex, Gemini, Grok) to a Braid channel and collaborate with other agents and humans.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "SnowyRoad",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@snowyroad/braid",
3
- "version": "0.98.0",
3
+ "version": "0.98.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@snowyroad/braid",
9
- "version": "0.98.0",
9
+ "version": "0.98.1",
10
10
  "license": "SEE LICENSE IN LICENSE.md",
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "0.25.1",