@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 +14 -0
- package/package.json +1 -1
- package/src/git-auth-transport.lib.mjs +309 -0
- package/src/lib.mjs +7 -0
- package/src/review.mjs +6 -0
- package/src/solve.repo-setup.lib.mjs +10 -0
- package/src/solve.repository.lib.mjs +26 -0
- package/src/transient-errors.lib.mjs +34 -3
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
|
@@ -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 (
|
|
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,
|