@link-assistant/hive-mind 2.15.1 → 2.16.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.
@@ -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
+ };
@@ -9,6 +9,8 @@ import { createInterface } from 'readline';
9
9
  import { log, cleanErrorMessage, getAbsoluteLogPath } from './lib.mjs';
10
10
  import { reportError, isSentryEnabled } from './sentry.lib.mjs';
11
11
  import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
12
+ import { sanitizeLogFileToFileBounded } from './log-sanitize-worker.lib.mjs';
13
+ import { readLogTailText } from './log-bounded-read.lib.mjs';
12
14
 
13
15
  if (typeof globalThis.use === 'undefined') {
14
16
  await ensureUseM();
@@ -107,8 +109,87 @@ const createSecretGist = async (logContent, filename) => {
107
109
  return null;
108
110
  };
109
111
 
112
+ /**
113
+ * Upload a log FILE as a secret gist without ever holding it in memory.
114
+ *
115
+ * Issue #2189: the error reporter runs when the process is already in trouble —
116
+ * frequently because it just exhausted its heap. Reading the log to sanitize it
117
+ * (`readFile` + `sanitizeForPublication` + write = three full copies) is the one
118
+ * thing that must not happen there.
119
+ *
120
+ * @param {string} logFilePath - Log to upload
121
+ * @param {string} filename - Name for the gist file
122
+ * @returns {Promise<string|null>} Gist URL, or null when the upload failed
123
+ */
124
+ const createSecretGistFromFile = async (logFilePath, filename) => {
125
+ const tempFile = `/tmp/${filename}`;
126
+ try {
127
+ await sanitizeLogFileToFileBounded({ sourcePath: logFilePath, destPath: tempFile });
128
+ const result = await $`gh gist create ${tempFile} --secret --desc "Error log for hive-mind"`;
129
+ if (result.exitCode === 0) {
130
+ return result.stdout.toString().trim();
131
+ }
132
+ } catch (error) {
133
+ reportError(error, {
134
+ context: 'create_secret_gist',
135
+ operation: 'gh_gist_create',
136
+ });
137
+ } finally {
138
+ await fs.unlink(tempFile).catch(() => {});
139
+ }
140
+ return null;
141
+ };
142
+
143
+ /**
144
+ * Format a log FILE for an issue body, choosing the attachment method from the
145
+ * file's size before reading any of it (issue #2189).
146
+ *
147
+ * Only the inline branch — by definition below GitHub's 60 kB issue-body limit —
148
+ * ever reads log content, and the truncated fallback reads a bounded tail.
149
+ *
150
+ * @param {string} logFilePath - Path to the log file
151
+ * @returns {Promise<{method: string, content: string}>}
152
+ */
153
+ export const formatLogFileForIssue = async logFilePath => {
154
+ const { size } = await fs.stat(logFilePath);
155
+
156
+ if (size < GITHUB_ISSUE_BODY_MAX_SIZE) {
157
+ const logContent = await fs.readFile(logFilePath, 'utf8');
158
+ return {
159
+ method: 'inline',
160
+ content: `\`\`\`\n${logContent}\n\`\`\``,
161
+ };
162
+ }
163
+
164
+ if (size < GITHUB_FILE_MAX_SIZE) {
165
+ return {
166
+ method: 'file',
167
+ content: `Log file is too large to include inline. Please see the attached log file.\n\nLog file path: \`${logFilePath}\``,
168
+ };
169
+ }
170
+
171
+ const gistUrl = await createSecretGistFromFile(logFilePath, `hive-mind-error-${Date.now()}.log`);
172
+ if (gistUrl) {
173
+ return {
174
+ method: 'gist',
175
+ content: `Log file is too large for inline attachment.\n\n📄 View full log: ${gistUrl}`,
176
+ };
177
+ }
178
+
179
+ const tail = await readLogTailText(logFilePath, { maxBytes: 5000 });
180
+ return {
181
+ method: 'truncated',
182
+ content: `Log file is too large. Showing last 5000 characters:\n\n\`\`\`\n${tail}\n\`\`\``,
183
+ };
184
+ };
185
+
110
186
  /**
111
187
  * Format log content for issue body
188
+ *
189
+ * Prefer {@link formatLogFileForIssue} when the log is a file on disk: this
190
+ * variant needs the whole log as a string, which is exactly what issue #2189
191
+ * removed from the publication path.
192
+ *
112
193
  * @param {string} logContent - Log file content
113
194
  * @param {string} logFilePath - Path to log file
114
195
  * @returns {Promise<Object>} Object with formatted content and attachment method
@@ -202,8 +283,9 @@ export const createIssueForError = async options => {
202
283
 
203
284
  if (logFile) {
204
285
  try {
205
- const logContent = await fs.readFile(logFile, 'utf8');
206
- const { method, content } = await formatLogForIssue(logContent, logFile);
286
+ // Issue #2189: pick the attachment method from the file size first; a
287
+ // log too large for the issue body is never read into memory here.
288
+ const { method, content } = await formatLogFileForIssue(logFile);
207
289
 
208
290
  issueBody += `### Log File\n\n${content}\n\n`;
209
291
  await log(`📄 Log attached via: ${method}`);