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/CHANGELOG.md +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
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 =
|
|
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
|
-
|
|
167
|
-
|
|
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 (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
//
|
|
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`)
|