opencode-harness-kit 0.5.4 → 0.5.6

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/src/trust.js CHANGED
@@ -32,6 +32,12 @@ import { fileURLToPath } from "node:url"
32
32
 
33
33
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
34
34
 
35
+ /** How long the two-factor wait polls for an answer. */
36
+ const POLL_MS = 5000
37
+
38
+ /** The window the two-factor wait is allowed to take, in seconds. */
39
+ const DEFAULT_WAIT_SECONDS = 600
40
+
35
41
  /** @param {string} s */
36
42
  const bold = (s) => `\x1b[1m${s}\x1b[0m`
37
43
  /** @param {string} s */
@@ -52,12 +58,20 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`
52
58
  * that form - and getting it wrong is one of the ways a configuration ends up
53
59
  * not matching.
54
60
  *
61
+ * Every form git and npm both accept is read here: an https URL, an ssh URL,
62
+ * and the `github:owner/repo` shorthand npm itself understands. A trailing
63
+ * slash does not make a different repository either, and it used to throw on
64
+ * one - so a package whose `repository` field ended in a slash could not be
65
+ * set up at all, and the failure was a stack trace rather than a sentence.
66
+ *
55
67
  * @param {any} repository
56
68
  * @returns {string}
57
69
  */
58
70
  export function repoOf(repository) {
59
71
  const url = typeof repository === "string" ? repository : repository?.url ?? ""
60
- const m = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/)
72
+ const m =
73
+ url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/) ??
74
+ url.match(/^github:([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
61
75
  if (!m) throw new Error(`cannot read a GitHub repository out of ${JSON.stringify(repository)}`)
62
76
  return `${m[1]}/${m[2]}`
63
77
  }
@@ -163,8 +177,15 @@ async function npmWithOtp(args, state = {}) {
163
177
  // JSON body. Without this flag the create cannot be completed at all, and
164
178
  // the failure says nothing about why.
165
179
  const withJson = args.includes("--json") ? [] : ["--json"]
166
- const userOtp = state.otp ? [`--otp=${state.otp}`] : []
167
- let result = npmCaptured([...args, ...withJson], userOtp)
180
+ // The call is made WITHOUT a code first.
181
+ //
182
+ // npm sends a code with the request that was CHALLENGED, and the code used
183
+ // to be attached to every call in the chain instead. So a code was spent on
184
+ // a call that never needed it, and the call that DID need it got a fresh
185
+ // challenge - which the tool then answered with another browser flow. The
186
+ // chain was: challenge, spend the code, challenge again, spend it again,
187
+ // and one browser tab per challenge.
188
+ let result = npmCaptured([...args, ...withJson])
168
189
  if (result.code === 0) return result
169
190
 
170
191
  // The URLs come FROM the body, not from a guess. Guessing the done URL
@@ -172,51 +193,86 @@ async function npmWithOtp(args, state = {}) {
172
193
  // answers "forbidden" - which is not a token, so the wait runs its full
173
194
  // length and then reports that nobody answered.
174
195
  const done = /"doneUrl"\s*:\s*"([^"]+)"/.exec(result.out)
175
- if (!done) return result
176
- const doneUrl = done[1]
177
- const auth = /"authUrl"\s*:\s*"([^"]+)"/.exec(result.out)
178
- const authId = /authId=([a-f0-9-]+)/.exec(doneUrl)
179
- const authUrl = auth ? auth[1] : `https://www.npmjs.com/auth/cli/${authId?.[1] ?? ""}`
196
+ if (done) {
197
+ const doneUrl = done[1]
198
+ const auth = /"authUrl"\s*:\s*"([^"]+)"/.exec(result.out)
199
+ const authId = /authId=([a-f0-9-]+)/.exec(doneUrl)
200
+ const authUrl = auth ? auth[1] : `https://www.npmjs.com/auth/cli/${authId?.[1] ?? ""}`
180
201
 
181
- // Open it if this machine can. The URL is printed either way, because a
182
- // browser that cannot be opened here can be opened anywhere.
183
- //
184
- // OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
185
- // a test that opens a browser on the machine it runs on is a test that
186
- // breaks whatever the person at the keyboard was doing.
187
- let opened = false
188
- if (!process.env.OC_TRUST_NO_BROWSER) {
189
- opened = openBrowser(authUrl)
190
- }
191
- console.error("")
192
- console.error(bold(" npm needs to confirm it is you."))
193
- console.error(` ${authUrl}`)
194
- console.error(dim(" Answer there however your npm account is set up - an authenticator"))
195
- console.error(dim(" app, a security key, or a code sent to your email."))
196
- // Saying the page is open when it is not sends the person looking for a
197
- // browser window that was never opened.
198
- console.error(
199
- dim(
200
- opened
201
- ? " This page is open in a browser, or copy the URL above."
202
- : " Open this page in a browser: the URL above is the way in.",
203
- ),
204
- )
205
- console.error("")
202
+ // Open it if this machine can. The URL is printed either way, because a
203
+ // browser that cannot be opened here can be opened anywhere.
204
+ //
205
+ // OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
206
+ // a test that opens a browser on the machine it runs on is a test that
207
+ // breaks whatever the person at the keyboard was doing.
208
+ let opened = false
209
+ if (!process.env.OC_TRUST_NO_BROWSER) {
210
+ opened = openBrowser(authUrl)
211
+ }
212
+ console.error("")
213
+ console.error(bold(" npm needs to confirm it is you."))
214
+ console.error(` ${authUrl}`)
215
+ console.error(dim(" Answer there however your npm account is set up - an authenticator"))
216
+ console.error(dim(" app, a security key, or a code sent to your email."))
217
+ // Saying the page is open when it is not sends the person looking for a
218
+ // browser window that was never opened.
219
+ console.error(
220
+ dim(
221
+ opened
222
+ ? " This page is open in a browser, or copy the URL above."
223
+ : " Open this page in a browser: the URL above is the way in.",
224
+ ),
225
+ )
226
+ console.error("")
206
227
 
207
- const token = await waitForToken(doneUrl)
208
- if (!token) {
209
- console.error(red(" no answer came back, so nothing was changed."))
228
+ const token = await waitForToken(doneUrl)
229
+ if (!token) {
230
+ console.error(
231
+ red(` no answer came back within ${waitSeconds()}s, so nothing was changed.`),
232
+ )
233
+ return result
234
+ }
235
+ // The code is used for THIS call only.
236
+ result = npmCaptured([...args, ...withJson], [`--otp=${token}`])
210
237
  return result
211
238
  }
212
- // The code is used for THIS call only.
213
- result = npmCaptured([...args, ...withJson], [`--otp=${token}`])
239
+
240
+ // The registry wants an authenticator code rather than a browser, and the
241
+ // user gave one.
242
+ //
243
+ // The code answers THIS challenge and is spent by it, so it is not carried
244
+ // to the next call in the chain: a spent code is not accepted, and sending
245
+ // one produces a fresh challenge rather than the answer.
246
+ if (state.otp && /EOTP|one-time password|need otp/i.test(result.out)) {
247
+ const otp = state.otp
248
+ state.otp = undefined
249
+ result = npmCaptured([...args, ...withJson], [`--otp=${otp}`])
250
+ }
214
251
  return result
215
252
  }
216
253
 
254
+ /**
255
+ * The window the two-factor wait is allowed to take.
256
+ *
257
+ * @returns {number} seconds
258
+ */
259
+ function waitSeconds() {
260
+ const seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? DEFAULT_WAIT_SECONDS)
261
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_WAIT_SECONDS
262
+ }
263
+
217
264
  /**
218
265
  * Wait for the browser to answer.
219
266
  *
267
+ * The wait is BOUNDED by the configured window, and the bound covers the
268
+ * request as well as the number of attempts.
269
+ *
270
+ * The request had no timeout of its own, and undici's default body timeout
271
+ * is five minutes - so an endpoint that sent its headers and then never
272
+ * ended its body held the command open for minutes per attempt, and the
273
+ * loop's own attempt count did not bound it at all. At the default window
274
+ * the worst case was hours, with nothing said about the wait.
275
+ *
220
276
  * @param {string} doneUrl
221
277
  * @returns {Promise<string | null>}
222
278
  */
@@ -224,21 +280,80 @@ async function waitForToken(doneUrl) {
224
280
  // How long to wait for a human. The window is generous, because the person
225
281
  // may be answering an email, and it is configurable so a CI run does not
226
282
  // hang on it.
227
- const seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? 600)
228
- const attempts = Math.max(1, Math.round(seconds / 5))
229
- for (let i = 0; i < attempts; i++) {
283
+ const deadline = Date.now() + waitSeconds() * 1000
284
+ while (Date.now() < deadline) {
285
+ // The remaining budget bounds the request itself, so a server that sends
286
+ // its headers and never ends its body cannot hold the command open past
287
+ // the window.
288
+ const controller = new AbortController()
289
+ const timer = setTimeout(() => controller.abort(), Math.max(1, deadline - Date.now()))
230
290
  try {
231
- const res = await fetch(doneUrl)
291
+ const res = await fetch(doneUrl, { signal: controller.signal })
232
292
  const body = /** @type {any} */ (await res.json())
233
293
  if (body && typeof body.token === "string" && body.token) return body.token
234
294
  } catch {
235
295
  // Not ready yet, or the network blipped. Try again.
296
+ } finally {
297
+ clearTimeout(timer)
236
298
  }
237
- await new Promise((r) => setTimeout(r, 5000))
299
+ // Wait for the next attempt, but never past the deadline.
300
+ const wait = Math.min(POLL_MS, deadline - Date.now())
301
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait))
238
302
  }
239
303
  return null
240
304
  }
241
305
 
306
+ /**
307
+ * The configurations in npm's own answer.
308
+ *
309
+ * npm prints ONE JSON OBJECT PER CONFIGURATION - one object per line, not
310
+ * an array of objects. The answer was parsed as a single JSON document, so a
311
+ * package with two configurations could not be read at all, and the tool
312
+ * said "could not read the trusted publisher configuration" about a package
313
+ * that had one. That is the opposite of a diagnostic: it hides the
314
+ * configuration the tool exists to repair.
315
+ *
316
+ * Both shapes are read: the ndjson npm actually prints, and the single
317
+ * object or array that other versions print.
318
+ *
319
+ * @param {string} out npm's answer
320
+ * @returns {{ configs: any[], failed: boolean, detail?: string }}
321
+ */
322
+ export function parseTrustList(out) {
323
+ const text = String(out ?? "").trim()
324
+ if (!text) return { configs: [], failed: false }
325
+
326
+ // One document first: a single object, or an array, on one line or
327
+ // pretty-printed over several.
328
+ let whole = null
329
+ try {
330
+ whole = JSON.parse(text)
331
+ } catch {
332
+ // Not one document. npm prints one object per line when a package has
333
+ // more than one configuration, and that is ndjson - not JSON.
334
+ }
335
+ const documents = whole === null ? text.split("\n").map((l) => l.trim()).filter(Boolean) : [text]
336
+
337
+ /** @type {any[]} */
338
+ const items = []
339
+ for (const doc of documents) {
340
+ let parsed
341
+ try {
342
+ parsed = JSON.parse(doc)
343
+ } catch {
344
+ // A line that is not JSON is npm's own noise, and an answer that
345
+ // cannot be read is a FAILURE - not an empty list. An empty list
346
+ // would say "no trusted publisher is configured", and the tool would
347
+ // then create one that already exists.
348
+ return { configs: [], failed: true, detail: out }
349
+ }
350
+ if (Array.isArray(parsed)) items.push(...parsed)
351
+ else if (parsed && Array.isArray(parsed.configs)) items.push(...parsed.configs)
352
+ else items.push(parsed)
353
+ }
354
+ return { configs: items.filter(Boolean), failed: false }
355
+ }
356
+
242
357
  /**
243
358
  * The stored trusted publisher configurations for this package.
244
359
  *
@@ -259,14 +374,7 @@ async function listConfigs(name, state = {}) {
259
374
  return { configs: [], failed: true, detail: out }
260
375
  }
261
376
  if (!out.trim()) return { configs: [], failed: false }
262
- let parsed
263
- try {
264
- parsed = JSON.parse(out)
265
- } catch {
266
- return { configs: [], failed: true, detail: out }
267
- }
268
- const items = Array.isArray(parsed) ? parsed : parsed.configs ?? [parsed]
269
- return { configs: items.filter(Boolean), failed: false }
377
+ return parseTrustList(out)
270
378
  }
271
379
 
272
380
  /**
@@ -289,11 +397,31 @@ function describe(config) {
289
397
  export async function main(argv = []) {
290
398
  const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"))
291
399
  const name = pkg.name
292
- const repo = repoOf(pkg.repository)
400
+ let repo
401
+ try {
402
+ repo = repoOf(pkg.repository)
403
+ } catch (err) {
404
+ // A diagnostic, not a stack trace.
405
+ //
406
+ // The throw used to escape the command and kill it with a JavaScript
407
+ // stack trace, which says nothing about the package.json field that
408
+ // was wrong - and the person reading it had to work out that the
409
+ // release tool was the thing that died.
410
+ console.error(red(`cannot work out the GitHub repository of ${name}.`))
411
+ console.error(dim(` ${err instanceof Error ? err.message : String(err)}`))
412
+ console.error(dim(" the `repository` field of package.json has to point at a GitHub repository."))
413
+ return 1
414
+ }
293
415
  const file = process.env.TRUSTED_PUBLISHER_FILE ?? "ci.yml"
294
416
  const checkOnly = argv.includes("--check")
295
- // A code given on the command line is used as-is. A code given at the
296
- // prompt is remembered for the rest of the run, so the user types it once.
417
+ // A code given on the command line is used as-is, and only for the call
418
+ // that was challenged.
419
+ //
420
+ // There is no prompt for a code. The challenge is answered in a browser,
421
+ // and npm's own helper only offers that when stdin AND stdout are both
422
+ // terminals - which a piped command is not. A code typed at a prompt would
423
+ // never arrive, so the comment about remembering it described a prompt
424
+ // that no longer existed.
297
425
  const state = { otp: argv.find((a) => a.startsWith("--otp="))?.slice("--otp=".length) }
298
426
 
299
427
  console.log(`\n${bold("npm trusted publishing")} ${dim(`${name} · ${repo} · ${file}`)}\n`)