@link-assistant/hive-mind 2.15.1 → 2.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.15.2
4
+
5
+ ### Patch Changes
6
+
7
+ - c1f6a41: Authenticate git downloads so a run no longer dies with `Reason: Repository setup failed` (issue #2192).
8
+
9
+ A run ended after three clone attempts, each answered by GitHub with `fatal: remote error: GitHub is temporarily limiting some unauthenticated downloads to protect the stability of the platform. Please retry later or authenticate.` — while every `gh api` call in the same process succeeded. Three defects lined up, and each is fixed:
10
+
11
+ - **Public clones were sent anonymously even though a token was available.** `gh auth setup-git` installs a credential helper, but git only consults a helper after the server answers `401`, and github.com answers `200` for a public repository. Measured with `GIT_TRACE_CURL=1`, a public `git clone`/`gh repo clone` sent **0** `Authorization` headers. The new `src/git-auth-transport.lib.mjs` sends the token preemptively via `http.https://github.com/.extraheader`, injected through `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` environment variables (**3** headers now sent) — so the token never reaches `.git/config` or a command line, and every git child process (`clone`, `fetch`, `pull`, `push`, `gh repo clone`) inherits it. `HIVE_MIND_DISABLE_GIT_AUTH_TRANSPORT=1` opts out.
12
+ - **The refusal was reported as `Unknown error`.** `classifyCloneError` now returns `ANONYMOUS_RATE_LIMIT`, and `src/transient-errors.lib.mjs` gains the shared `github-anonymous-rate-limit` category, so the log names the real cause and the "How to fix" section stops suggesting `gh auth login` to an already-logged-in run.
13
+ - **Retries only slept.** The clone loop and `gitCmdRetry` now upgrade the transport before the next attempt, falling back to `gh-setup-git-identity --repair` (non-interactive) when no token is reachable; only a genuinely absent token still fails.
14
+
15
+ Authentication now happens _before_ the first clone in `setupRepositoryAndClone`, `review`, and `create-test-repo`. Full analysis, the run log excerpts and a reproduction script are in `docs/case-studies/issue-2192/`.
16
+
3
17
  ## 2.15.1
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.15.1",
3
+ "version": "2.15.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Authenticated git HTTPS transport for github.com (issue #2192).
5
+ *
6
+ * A `solve` run died with `Reason: Repository setup failed` after three clone
7
+ * attempts, each rejected by GitHub with:
8
+ *
9
+ * fatal: remote error: GitHub is temporarily limiting some unauthenticated
10
+ * downloads to protect the stability of the platform. Please retry later or
11
+ * authenticate.
12
+ *
13
+ * The container *was* authenticated: every `gh` API call in the same run
14
+ * succeeded, and `gh auth setup-git` had installed
15
+ * `credential.https://github.com.helper = !gh auth git-credential`.
16
+ *
17
+ * A credential helper does not help here. git only asks the helper for
18
+ * credentials **after** the server answers `401`, and github.com answers `200`
19
+ * for a public repository — so a public clone is performed anonymously even
20
+ * when a token is sitting right there. Verified with `GIT_TRACE_CURL=1`:
21
+ * `git clone` and `gh repo clone` of a public repository send zero
22
+ * `Authorization` headers with the helper configured (see
23
+ * `experiments/issue-2192-anonymous-clone-auth.mjs`). Anonymous traffic is what
24
+ * GitHub throttles, so the run hit the limit and no amount of retrying or
25
+ * credential-helper repair could have fixed it.
26
+ *
27
+ * The remedy is the one GitHub's own `actions/checkout` uses: send the token
28
+ * preemptively via `http.<host>.extraheader`. Two properties matter here:
29
+ *
30
+ * 1. The header is injected through `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` /
31
+ * `GIT_CONFIG_VALUE_n` environment variables (git >= 2.31) rather than
32
+ * written into `.git/config` or passed as `-c` arguments. The token never
33
+ * lands in a file the AI session can read back, never appears in a
34
+ * process command line visible to `ps`, and is inherited by every git
35
+ * child process — `gh repo clone`, `git fetch`, `git pull`, `git push` —
36
+ * without touching those ~36 call sites.
37
+ * 2. Existing `GIT_CONFIG_*` entries are preserved: the new keys are appended
38
+ * after whatever the operator (or an outer container) already configured.
39
+ *
40
+ * The token value is protected in logs by the existing sanitizer: base64 forms
41
+ * of known local tokens are masked by `findEncodedKnownTokenRuns`
42
+ * (issue #2156), and the header is never logged by this module in any case.
43
+ *
44
+ * @see docs/case-studies/issue-2192/README.md
45
+ * @module git-auth-transport
46
+ */
47
+
48
+ import { ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS, isAnonymousDownloadLimit } from './transient-errors.lib.mjs';
49
+
50
+ /** Hosts whose HTTPS git traffic is authenticated by default. */
51
+ export const DEFAULT_AUTHENTICATED_HOSTS = Object.freeze(['github.com']);
52
+
53
+ /** Env var used as the idempotency marker / diagnostic breadcrumb. */
54
+ export const GIT_AUTH_TRANSPORT_MARKER = 'HIVE_MIND_GIT_AUTH_TRANSPORT';
55
+
56
+ /** Env var an operator can set to opt out of forced authentication. */
57
+ export const GIT_AUTH_TRANSPORT_DISABLE = 'HIVE_MIND_DISABLE_GIT_AUTH_TRANSPORT';
58
+
59
+ // GitHub's wording when it refuses an *anonymous* git download lives in the
60
+ // shared transient vocabulary (`transient-errors.lib.mjs`) so classification
61
+ // and repair can never drift apart; re-exported for callers that only import
62
+ // this module.
63
+ export { ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS, isAnonymousDownloadLimit };
64
+
65
+ /**
66
+ * Build the `Authorization` header value git should send to github.com.
67
+ *
68
+ * GitHub accepts the token as the *password* of HTTP Basic auth with any
69
+ * username; `x-access-token` is the username `actions/checkout` uses.
70
+ *
71
+ * @param {string} token
72
+ * @returns {string} e.g. `Authorization: Basic eC1hY2Nlc3M...`
73
+ */
74
+ export const buildAuthorizationHeader = token => `Authorization: Basic ${Buffer.from(`x-access-token:${token}`, 'utf8').toString('base64')}`;
75
+
76
+ /**
77
+ * Read the number of `GIT_CONFIG_*` pairs already present in `env`.
78
+ *
79
+ * Anything unparseable is treated as zero rather than throwing: a malformed
80
+ * outer environment must not stop the solver from cloning.
81
+ *
82
+ * @param {Record<string, string|undefined>} env
83
+ * @returns {number}
84
+ */
85
+ export const readGitConfigCount = env => {
86
+ const parsed = Number.parseInt(String(env?.GIT_CONFIG_COUNT ?? ''), 10);
87
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
88
+ };
89
+
90
+ /**
91
+ * True when `env` already carries an `extraheader` entry for every host.
92
+ *
93
+ * Recognises entries installed by this module *and* by an outer environment
94
+ * (a CI runner, a parent container), so authentication configured upstream is
95
+ * never duplicated.
96
+ *
97
+ * @param {Record<string, string|undefined>} env
98
+ * @param {string[]} [hosts]
99
+ * @returns {boolean}
100
+ */
101
+ export const hasGitAuthConfig = (env, hosts = DEFAULT_AUTHENTICATED_HOSTS) => {
102
+ const count = readGitConfigCount(env);
103
+ const configuredKeys = new Set();
104
+ for (let index = 0; index < count; index++) {
105
+ const key = env?.[`GIT_CONFIG_KEY_${index}`];
106
+ const value = env?.[`GIT_CONFIG_VALUE_${index}`];
107
+ if (typeof key === 'string' && typeof value === 'string' && value.trim()) configuredKeys.add(key.toLowerCase());
108
+ }
109
+ return hosts.every(host => configuredKeys.has(gitAuthConfigKey(host).toLowerCase()));
110
+ };
111
+
112
+ /**
113
+ * git config key that carries the preemptive `Authorization` header for `host`.
114
+ *
115
+ * The trailing slash matters: git matches `http.<url>.*` by URL prefix, so
116
+ * `https://github.com/` covers every repository on the host and nothing else.
117
+ *
118
+ * @param {string} host
119
+ * @returns {string}
120
+ */
121
+ export const gitAuthConfigKey = host => `http.https://${host}/.extraheader`;
122
+
123
+ /**
124
+ * Compute the `GIT_CONFIG_*` variables that add preemptive authentication for
125
+ * `hosts`, preserving any entries already present in `env`.
126
+ *
127
+ * Pure: returns a patch, mutates nothing.
128
+ *
129
+ * @param {object} params
130
+ * @param {string} params.token - GitHub token
131
+ * @param {string[]} [params.hosts]
132
+ * @param {Record<string, string|undefined>} [params.env] - environment to extend
133
+ * @returns {Record<string, string>} variables to merge into the environment
134
+ */
135
+ export const buildGitAuthConfigEnv = ({ token, hosts = DEFAULT_AUTHENTICATED_HOSTS, env = {} }) => {
136
+ if (!token) throw new TypeError('buildGitAuthConfigEnv requires a GitHub token');
137
+ const header = buildAuthorizationHeader(token);
138
+ const patch = {};
139
+ let index = readGitConfigCount(env);
140
+ for (const host of hosts) {
141
+ patch[`GIT_CONFIG_KEY_${index}`] = gitAuthConfigKey(host);
142
+ patch[`GIT_CONFIG_VALUE_${index}`] = header;
143
+ index++;
144
+ }
145
+ patch.GIT_CONFIG_COUNT = String(index);
146
+ patch[GIT_AUTH_TRANSPORT_MARKER] = hosts.join(',');
147
+ return patch;
148
+ };
149
+
150
+ /**
151
+ * True when the operator disabled forced authentication.
152
+ *
153
+ * @param {Record<string, string|undefined>} env
154
+ * @returns {boolean}
155
+ */
156
+ export const isGitAuthTransportDisabled = (env = process.env) => {
157
+ const raw = String(env?.[GIT_AUTH_TRANSPORT_DISABLE] ?? '')
158
+ .trim()
159
+ .toLowerCase();
160
+ return raw === '1' || raw === 'true' || raw === 'yes';
161
+ };
162
+
163
+ /**
164
+ * Resolve a GitHub token without ever printing it.
165
+ *
166
+ * Order: explicit env vars first (an operator override beats stored state),
167
+ * then `gh auth token`. Returns `{ token, source }` or `{ token: null }`.
168
+ *
169
+ * @param {object} params
170
+ * @param {Function} params.$ - command-stream `$` tag
171
+ * @param {Record<string, string|undefined>} [params.env]
172
+ * @returns {Promise<{token: string|null, source: string|null, error: string|null}>}
173
+ */
174
+ /**
175
+ * Return the caller's `$` or load command-stream's on demand.
176
+ *
177
+ * @param {Function|undefined} provided
178
+ * @returns {Promise<Function>} command-stream `$` tag
179
+ */
180
+ export const resolveDollar = async provided => {
181
+ if (typeof provided === 'function') return provided;
182
+ // Call sites deep in the retry helpers do not carry a `$` of their own; fall
183
+ // back to the same command-stream instance the rest of the codebase loads.
184
+ const { ensureUseM } = await import('./use-m-bootstrap.lib.mjs');
185
+ const use = globalThis.use || (await ensureUseM());
186
+ return (await use('command-stream')).$;
187
+ };
188
+
189
+ export const resolveGitHubToken = async ({ $, env = process.env }) => {
190
+ for (const name of ['GH_TOKEN', 'GITHUB_TOKEN']) {
191
+ const value = env?.[name];
192
+ if (typeof value === 'string' && value.trim()) return { token: value.trim(), source: name, error: null };
193
+ }
194
+ try {
195
+ // `quietProbe` keeps the token out of the mirrored output that becomes the
196
+ // attached log (issue #2130); the value is only ever used in memory.
197
+ const { quietProbe } = await import('./quiet-probe.lib.mjs');
198
+ const result = await quietProbe(await resolveDollar($))`gh auth token`;
199
+ const token = (result?.stdout?.toString() || '').trim();
200
+ if (result?.code === 0 && token) return { token, source: 'gh auth token', error: null };
201
+ return { token: null, source: null, error: (result?.stderr?.toString() || '').trim() || `gh auth token exited ${result?.code}` };
202
+ } catch (error) {
203
+ return { token: null, source: null, error: error?.message || String(error) };
204
+ }
205
+ };
206
+
207
+ /**
208
+ * Ensure git sends credentials to github.com preemptively.
209
+ *
210
+ * Idempotent, never throws, and safe to call from any entry point: the worst
211
+ * case (no token, gh not installed, operator opt-out) leaves the environment
212
+ * exactly as it was and reports why.
213
+ *
214
+ * @param {object} params
215
+ * @param {Function} [params.$] - command-stream `$` tag (loaded on demand when omitted)
216
+ * @param {Function} [params.log] - logger; called with human-readable lines
217
+ * @param {Record<string, string|undefined>} [params.env] - environment to mutate (default `process.env`)
218
+ * @param {string[]} [params.hosts]
219
+ * @param {string} [params.reason] - short phrase explaining why it was invoked
220
+ * @param {boolean} [params.repair] - when true, try `gh-setup-git-identity --repair` if no token is found
221
+ * @returns {Promise<{status: 'applied'|'already-configured'|'disabled'|'no-token', hosts: string[], source?: string|null, error?: string|null}>}
222
+ */
223
+ export const ensureAuthenticatedGitTransport = async ({ $, log = async () => {}, env = process.env, hosts = DEFAULT_AUTHENTICATED_HOSTS, reason = '', repair = false } = {}) => {
224
+ if (isGitAuthTransportDisabled(env)) {
225
+ await log(`ℹ️ Authenticated git transport disabled via ${GIT_AUTH_TRANSPORT_DISABLE}; git will download from github.com anonymously`, { verbose: true });
226
+ return { status: 'disabled', hosts };
227
+ }
228
+
229
+ if (hasGitAuthConfig(env, hosts)) {
230
+ // Issue #2192 asks for enough diagnostics to reconstruct the transport state
231
+ // from a log. Verbose-only: on the happy path this fires before every clone.
232
+ await log(`🔐 Authenticated git transport already configured for ${hosts.join(', ')}${reason ? ` - ${reason}` : ''}`, { verbose: true });
233
+ return { status: 'already-configured', hosts };
234
+ }
235
+
236
+ let { token, source, error } = await resolveGitHubToken({ $, env });
237
+
238
+ if (!token && repair) {
239
+ // Issue #2192 asks for auto-recovery of the git/gh state when it is
240
+ // repairable without interactive credentials. `gh-setup-git-identity`
241
+ // derives the identity and the credential helper from the authenticated
242
+ // gh account; it cannot invent a token, so this only helps when gh state
243
+ // is broken rather than logged out.
244
+ const { repairGitIdentity } = await import('./git.lib.mjs');
245
+ const repaired = await repairGitIdentity();
246
+ await log(repaired.success ? '🔧 Repaired git/gh configuration with gh-setup-git-identity --repair' : `ℹ️ gh-setup-git-identity repair unavailable: ${repaired.error}`, { verbose: !repaired.success });
247
+ if (repaired.success) ({ token, source, error } = await resolveGitHubToken({ $, env }));
248
+ }
249
+
250
+ if (!token) {
251
+ await log(`⚠️ No GitHub token available for git transport${error ? ` (${error.split('\n')[0]})` : ''} - downloads from ${hosts.join(', ')} stay anonymous and can be throttled`, { level: 'warning' });
252
+ return { status: 'no-token', hosts, error };
253
+ }
254
+
255
+ Object.assign(env, buildGitAuthConfigEnv({ token, hosts, env }));
256
+ await log(`🔐 Authenticated git transport enabled for ${hosts.join(', ')} (token source: ${source})${reason ? ` - ${reason}` : ''}`, { verbose: true });
257
+ return { status: 'applied', hosts, source };
258
+ };
259
+
260
+ /**
261
+ * Point git at `gh` for credentials when nothing is configured yet.
262
+ *
263
+ * This is the *401* half of authentication (private repositories, pushes); the
264
+ * `extraheader` above is the *200* half (public downloads). Both are needed:
265
+ * neither can replace the other.
266
+ *
267
+ * @param {object} params
268
+ * @param {Function} params.$ - command-stream `$` tag
269
+ * @param {Function} [params.log]
270
+ * @param {string} [params.host]
271
+ * @returns {Promise<{status: 'present'|'configured'|'failed', error?: string}>}
272
+ */
273
+ export const ensureGlobalCredentialHelper = async ({ $, log = async () => {}, host = 'github.com' }) => {
274
+ const { quietProbe } = await import('./quiet-probe.lib.mjs');
275
+ const dollar = await resolveDollar($);
276
+ const existing = await quietProbe(dollar)`git config --get-all ${`credential.https://${host}.helper`}`;
277
+ const configured = (existing?.stdout?.toString() || '')
278
+ .split('\n')
279
+ .map(line => line.trim())
280
+ .filter(Boolean);
281
+ if (configured.length > 0) return { status: 'present' };
282
+
283
+ const setup = await quietProbe(dollar)`gh auth setup-git 2>&1`;
284
+ if (setup?.code === 0) {
285
+ await log(`🔑 Configured the gh credential helper for ${host}`, { verbose: true });
286
+ return { status: 'configured' };
287
+ }
288
+ const reason = (setup?.stdout?.toString() || setup?.stderr?.toString() || '').trim().split('\n')[0];
289
+ await log(`ℹ️ Could not configure a global git credential helper for ${host}${reason ? `: ${reason}` : ''}`, { verbose: true });
290
+ return { status: 'failed', error: reason };
291
+ };
292
+
293
+ export default {
294
+ ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS,
295
+ DEFAULT_AUTHENTICATED_HOSTS,
296
+ GIT_AUTH_TRANSPORT_DISABLE,
297
+ GIT_AUTH_TRANSPORT_MARKER,
298
+ buildAuthorizationHeader,
299
+ buildGitAuthConfigEnv,
300
+ ensureAuthenticatedGitTransport,
301
+ ensureGlobalCredentialHelper,
302
+ gitAuthConfigKey,
303
+ hasGitAuthConfig,
304
+ resolveDollar,
305
+ isAnonymousDownloadLimit,
306
+ isGitAuthTransportDisabled,
307
+ readGitConfigCount,
308
+ resolveGitHubToken,
309
+ };
package/src/lib.mjs CHANGED
@@ -710,6 +710,13 @@ export const gitCmdRetry = async (cmdFn, options = {}) => {
710
710
  const description = describeTransientError({ message: combinedOutput });
711
711
 
712
712
  if (description.transient && attempt < maxAttempts) {
713
+ // Issue #2192: GitHub rejected the request because it arrived
714
+ // *unauthenticated*. Waiting does not change that, so authenticate the
715
+ // transport (repairing the gh state if needed) before the next attempt.
716
+ if (description.category === 'github-anonymous-rate-limit') {
717
+ const { ensureAuthenticatedGitTransport } = await import('./git-auth-transport.lib.mjs');
718
+ await ensureAuthenticatedGitTransport({ log: logFn, repair: true, reason: `${label} rejected as unauthenticated` });
719
+ }
713
720
  const waitTime = delay * Math.pow(backoff, attempt - 1);
714
721
  await logFn(`⚠️ ${label}: transient git error (attempt ${attempt}/${maxAttempts}), retrying in ${Math.round(waitTime / 1000)}s... [${formatTransientDiagnostics(description)}]`, { level: 'warn' });
715
722
  await sleep(waitTime);
package/src/review.mjs CHANGED
@@ -50,6 +50,7 @@ const fs = (await use('fs')).promises;
50
50
  import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
51
51
  import { QUIET_PROBE } from './quiet-probe.lib.mjs';
52
52
  import { reportError } from './sentry.lib.mjs';
53
+ import { ensureAuthenticatedGitTransport } from './git-auth-transport.lib.mjs'; // issue #2192
53
54
  import * as memoryCheck from './memory-check.mjs';
54
55
 
55
56
  // Import Claude execution functions
@@ -244,6 +245,11 @@ try {
244
245
  await log(`📝 Files changed: ${prDetails.files.length}`);
245
246
 
246
247
  // Clone the repository using gh tool with authentication
248
+ // Issue #2192: authenticate the git transport first. `gh repo clone` of a
249
+ // public repository sends no Authorization header (the credential helper is
250
+ // only consulted after a 401, and github.com answers 200), so without this the
251
+ // clone is anonymous and can be refused by GitHub's unauthenticated-download limiter.
252
+ await ensureAuthenticatedGitTransport({ $, log, reason: 'review clone' });
247
253
  await log(`\nCloning repository ${owner}/${repo} using gh tool...\n`);
248
254
  const cloneResult = await $`gh repo clone ${owner}/${repo} ${tempDir}`;
249
255
 
@@ -7,8 +7,18 @@
7
7
  // issue comment so it's excluded from --auto-attach-solution-summary's check.
8
8
  import { REPOSITORY_INITIALIZATION_REQUIRED_MARKER, postTrackedComment } from './tool-comments.lib.mjs';
9
9
  import { QUIET_PROBE } from './quiet-probe.lib.mjs'; // issue #2130: keep read-only probe payloads out of the attached log
10
+ // Issue #2192: a credential helper is never consulted for a *public* clone
11
+ // (github.com answers 200, so git never asks), which is why an authenticated
12
+ // container still got throttled as anonymous. The token has to be sent
13
+ // preemptively, before the first git network call.
14
+ import { ensureAuthenticatedGitTransport } from './git-auth-transport.lib.mjs';
10
15
 
11
16
  export async function setupRepositoryAndClone({ argv, owner, repo, forkOwner, forkRepoName, tempDir, isContinueMode, issueUrl, log, $, needsClone = true }) {
17
+ // Issue #2192: authenticate git *before* the first clone/fetch. Doing this
18
+ // afterwards (as setupGitCredentialHelper does) is too late — the clone is the
19
+ // call GitHub rejected with "temporarily limiting some unauthenticated downloads".
20
+ await ensureAuthenticatedGitTransport({ $, log, reason: 'repository setup' });
21
+
12
22
  // Set up repository and handle forking
13
23
  const { repoToClone, forkedRepo, upstreamRemote, prForkOwner } = await setupRepository(argv, owner, repo, forkOwner, issueUrl, forkRepoName);
14
24
 
@@ -31,6 +31,9 @@ import { ensureAiToolScratchIgnored } from './ai-tool-scratch.lib.mjs';
31
31
  import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
32
32
  import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
33
33
  import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
34
+ // Issue #2192: GitHub throttles *anonymous* git downloads; a token must be sent
35
+ // preemptively (a credential helper is never consulted for a public repository).
36
+ import { GIT_AUTH_TRANSPORT_DISABLE, ensureAuthenticatedGitTransport, isAnonymousDownloadLimit } from './git-auth-transport.lib.mjs';
34
37
 
35
38
  // Import GitHub utilities for permission checks
36
39
  const githubLib = await import('./github.lib.mjs');
@@ -949,6 +952,14 @@ export const classifyCloneError = errorOutput => {
949
952
  return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue (interrupted transfer)' };
950
953
  }
951
954
 
955
+ // Issue #2192: GitHub refusing an *unauthenticated* download. Retryable, but
956
+ // waiting is not the remedy — the clone has to be authenticated. Checked
957
+ // before PERMISSION/NOT_FOUND/RATE_LIMIT because GitHub's wording ("limiting",
958
+ // "retry later or authenticate") overlaps all three.
959
+ if (isAnonymousDownloadLimit(errorOutput)) {
960
+ return { type: 'ANONYMOUS_RATE_LIMIT', retryable: true, description: 'GitHub is limiting unauthenticated downloads (this clone was not authenticated)' };
961
+ }
962
+
952
963
  // Authentication/permission errors - not retryable
953
964
  if (output.includes('error: 401') || output.includes('error: 403') || output.includes('authentication failed') || output.includes('permission denied')) {
954
965
  return { type: 'PERMISSION', retryable: false, description: 'Authentication or permission error' };
@@ -1075,6 +1086,9 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1075
1086
  await log(' • Network connectivity issues');
1076
1087
  if (errorClassification.type === 'TRANSIENT') await log(' • GitHub server issues (temporary)');
1077
1088
  if (errorClassification.type === 'RATE_LIMIT') await log(' • API rate limiting exceeded');
1089
+ // Issue #2192: the request never carried an Authorization header, so
1090
+ // GitHub counted it against the anonymous budget regardless of `gh auth status`.
1091
+ if (errorClassification.type === 'ANONYMOUS_RATE_LIMIT') await log(' • The clone was sent anonymously — GitHub throttles unauthenticated downloads');
1078
1092
  // Issue #1957: the transfer started but was interrupted (e.g. the connection
1079
1093
  // dropped while reading the pack). The retries above were already exhausted.
1080
1094
  if (errorClassification.type === 'NETWORK') await log(' • Connection dropped mid-transfer (the clone was interrupted before completing)');
@@ -1087,6 +1101,11 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1087
1101
  if (argv.fork) await log(` 4. Check fork: gh repo view ${repoToClone}`);
1088
1102
  if (errorClassification.type === 'TRANSIENT') await log(' 5. Wait and retry / check: https://www.githubstatus.com');
1089
1103
  if (errorClassification.type === 'RATE_LIMIT') await log(' 5. Wait for rate limit to reset or use --token with different token');
1104
+ if (errorClassification.type === 'ANONYMOUS_RATE_LIMIT') {
1105
+ await log(' 5. Make sure a token is available to git: gh auth token (or set GH_TOKEN)');
1106
+ await log(' 6. Repair the git/gh state non-interactively: gh-setup-git-identity --repair');
1107
+ await log(` 7. Hive Mind normally authenticates git itself; if that was turned off, unset ${GIT_AUTH_TRANSPORT_DISABLE}`);
1108
+ }
1090
1109
  if (errorClassification.type === 'NETWORK') {
1091
1110
  await log(' 5. Check your network connection / VPN / proxy, then re-run the command');
1092
1111
  await log(' 6. On slow or unstable links, a shallower history transfers faster and is less');
@@ -1100,6 +1119,13 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1100
1119
  // Retryable error and we have attempts left
1101
1120
  const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
1102
1121
  await log(`${formatAligned('⚠️', 'Clone failed:', errorClassification.description)}`);
1122
+ // Issue #2192: auto-recovery. GitHub rejected the download as anonymous, so a
1123
+ // plain retry would be rejected the same way. Authenticate the transport (and,
1124
+ // if no token is reachable, let `gh-setup-git-identity --repair` restore the
1125
+ // gh state non-interactively) before spending the next attempt.
1126
+ if (errorClassification.type === 'ANONYMOUS_RATE_LIMIT') {
1127
+ await ensureAuthenticatedGitTransport({ $, log, repair: true, reason: 'GitHub rejected the clone as unauthenticated' });
1128
+ }
1103
1129
  await log(`${formatAligned('⏳', 'Retrying:', `Waiting ${delay / 1000}s before attempt ${attempt + 1}/${maxRetries}...`)}`);
1104
1130
  if (errorClassification.type === 'RATE_LIMIT') {
1105
1131
  await log(' 💡 Tip: Rate limiting detected - using longer delay');
@@ -75,11 +75,25 @@ export const GITHUB_SERVER_TRANSIENT_PATTERNS = Object.freeze([
75
75
  */
76
76
  export const GIT_TRANSIENT_PATTERNS = Object.freeze(['unexpected disconnect', 'sideband', 'early eof', 'the remote end hung up', 'remote end hung up unexpectedly', 'rpc failed', 'fetch-pack', 'index-pack failed', 'transfer closed', 'unable to access', 'could not read from remote repository', 'failed to connect to github.com', 'operation timed out after', 'gnutls_handshake() failed', 'the requested url returned error: 5']);
77
77
 
78
+ /**
79
+ * GitHub throttling *anonymous* git downloads (issue #2192):
80
+ *
81
+ * fatal: remote error: GitHub is temporarily limiting some unauthenticated
82
+ * downloads to protect the stability of the platform. Please retry later or
83
+ * authenticate.
84
+ *
85
+ * Retryable — GitHub itself says "retry later" — but the *real* fix is to
86
+ * authenticate, which `src/git-auth-transport.lib.mjs` does before retrying.
87
+ * Kept as its own category so a run that hits this is never diagnosed as a
88
+ * generic network fault (the failing run reported "Unknown error" three times).
89
+ */
90
+ export const ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS = Object.freeze(['temporarily limiting some unauthenticated downloads', 'limiting some unauthenticated downloads', 'please retry later or authenticate', 'retry later or authenticate']);
91
+
78
92
  /**
79
93
  * Union used by the general-purpose `isTransientNetworkError` helpers. Kept as
80
94
  * a single flat list so a caller cannot accidentally miss a category.
81
95
  */
82
- export const ALL_TRANSIENT_PATTERNS = Object.freeze([...NETWORK_TRANSIENT_PATTERNS, ...GITHUB_SERVER_TRANSIENT_PATTERNS, ...GIT_TRANSIENT_PATTERNS]);
96
+ export const ALL_TRANSIENT_PATTERNS = Object.freeze([...NETWORK_TRANSIENT_PATTERNS, ...GITHUB_SERVER_TRANSIENT_PATTERNS, ...GIT_TRANSIENT_PATTERNS, ...ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS]);
83
97
 
84
98
  /**
85
99
  * Pull every plausible string out of an error-ish value so pattern matches
@@ -126,6 +140,15 @@ const matchPattern = (error, patterns) => {
126
140
  */
127
141
  export const isTransientNetworkError = error => matchPattern(error, ALL_TRANSIENT_PATTERNS) !== null;
128
142
 
143
+ /**
144
+ * True when `error` is GitHub refusing an *unauthenticated* git download.
145
+ * The remedy is to authenticate the transport, not merely to wait.
146
+ *
147
+ * @param {unknown} error
148
+ * @returns {boolean}
149
+ */
150
+ export const isAnonymousDownloadLimit = error => matchPattern(error, ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS) !== null;
151
+
129
152
  /**
130
153
  * True when `error` is a GitHub server-side fault (5xx or GraphQL internal).
131
154
  * Narrower than `isTransientNetworkError` — used for logging/classification.
@@ -174,7 +197,7 @@ export const parseGitHubRequestId = error => {
174
197
  * Full classification of a failure, for retry decisions *and* for diagnostics.
175
198
  *
176
199
  * @param {unknown} error
177
- * @returns {{transient: boolean, category: 'network'|'github-server'|'git-transport'|null, matchedPattern: string|null, requestId: string|null, text: string}}
200
+ * @returns {{transient: boolean, category: 'network'|'github-server'|'git-transport'|'github-anonymous-rate-limit'|null, matchedPattern: string|null, requestId: string|null, text: string}}
178
201
  */
179
202
  export const describeTransientError = error => {
180
203
  const text = collectErrorText(error);
@@ -184,10 +207,16 @@ export const describeTransientError = error => {
184
207
  const networkPattern = find(NETWORK_TRANSIENT_PATTERNS);
185
208
  const serverPattern = find(GITHUB_SERVER_TRANSIENT_PATTERNS);
186
209
  const gitPattern = find(GIT_TRANSIENT_PATTERNS);
210
+ const anonymousPattern = find(ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS);
187
211
 
188
212
  let category = null;
189
213
  let matchedPattern = null;
190
- if (networkPattern) {
214
+ if (anonymousPattern) {
215
+ // Checked first: this failure has a specific remedy (authenticate) and its
216
+ // text also mentions timeouts/retries that the other lists could match.
217
+ category = 'github-anonymous-rate-limit';
218
+ matchedPattern = anonymousPattern;
219
+ } else if (networkPattern) {
191
220
  category = 'network';
192
221
  matchedPattern = networkPattern;
193
222
  } else if (serverPattern) {
@@ -224,11 +253,13 @@ export const formatTransientDiagnostics = description => {
224
253
  };
225
254
 
226
255
  export default {
256
+ ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS,
227
257
  NETWORK_TRANSIENT_PATTERNS,
228
258
  GITHUB_SERVER_TRANSIENT_PATTERNS,
229
259
  GIT_TRANSIENT_PATTERNS,
230
260
  ALL_TRANSIENT_PATTERNS,
231
261
  collectErrorText,
262
+ isAnonymousDownloadLimit,
232
263
  isTransientNetworkError,
233
264
  isGitHubServerError,
234
265
  matchTransientPattern,