@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.
- package/CHANGELOG.md +33 -0
- package/package.json +1 -1
- package/src/bot-lifecycle.lib.mjs +8 -1
- package/src/child-exit.lib.mjs +53 -1
- package/src/claude.session-tokens.lib.mjs +10 -8
- package/src/claude.session-transcript-repair.lib.mjs +65 -34
- package/src/codex.lib.mjs +11 -1
- package/src/development-log.lib.mjs +22 -7
- package/src/git-auth-transport.lib.mjs +309 -0
- package/src/github-error-reporter.lib.mjs +84 -2
- package/src/github.lib.mjs +212 -152
- package/src/lib.mjs +7 -0
- package/src/log-bounded-read.lib.mjs +411 -0
- package/src/log-sanitize-stream.lib.mjs +267 -0
- package/src/log-sanitize-worker-entry.mjs +31 -0
- package/src/log-sanitize-worker.lib.mjs +186 -0
- package/src/log-upload.lib.mjs +16 -4
- package/src/review.mjs +6 -0
- package/src/session-completion-state.lib.mjs +124 -0
- package/src/session-kill-diagnostics.lib.mjs +73 -10
- package/src/session-kill-policy.lib.mjs +18 -7
- package/src/session-kill-resume.lib.mjs +5 -2
- package/src/session-monitor.lib.mjs +132 -9
- package/src/session-store.lib.mjs +15 -1
- package/src/solve.config.lib.mjs +5 -2
- package/src/solve.repo-setup.lib.mjs +10 -0
- package/src/solve.repository.lib.mjs +26 -0
- package/src/solve.resource-diagnostics.lib.mjs +71 -4
- package/src/telegram-log-command.lib.mjs +7 -3
- package/src/telegram-terminal-watch-command.lib.mjs +9 -1
- package/src/transient-errors.lib.mjs +34 -3
|
@@ -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
|
-
|
|
206
|
-
|
|
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}`);
|