opencode-harness-kit 0.3.7 → 0.3.9

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 CHANGED
@@ -14,6 +14,48 @@ is not happening.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.3.9] — 2026-10-20
18
+
19
+ ### Fixed
20
+
21
+ - **Each npm command gets its own two-factor challenge.** The code a browser
22
+ flow returns is valid for the challenge that asked for it and for nothing
23
+ else, so carrying one from one command to the next made the second command
24
+ send a code that was already spent - and the registry answered that with a
25
+ different challenge, one that wants an authenticator code rather than a
26
+ browser. The failure looked like "npm wants something else now" and nothing
27
+ like the cause.
28
+ - **The URLs come from the response body, not from a guess.** The done URL
29
+ was rebuilt from the id, which sent a real request to the registry with a
30
+ made-up id; the registry answered "forbidden", which is not a token, so the
31
+ wait ran its full length and then reported that nobody had answered.
32
+ - **`OC_TRUST_NO_BROWSER` turns the browser off.** A test that opens a
33
+ browser on the machine it runs on is a test that breaks whatever the
34
+ person at the keyboard was doing.
35
+
36
+ ## [0.3.8] — 2026-10-20
37
+
38
+ ### Fixed
39
+
40
+ - **`oc-harness trust` completes the two-factor challenge the way npm does, so
41
+ it works with whatever second factor the account has.** npm answers a
42
+ request that needs one with the URL to authenticate at and the URL to
43
+ collect the result from - the URL in the error text is masked, but the one
44
+ in the JSON body is not.
45
+
46
+ The URL is opened in a browser and printed, the result is collected, and the
47
+ command carries on. That works with an authenticator app, a security key,
48
+ or a code sent to the user's email - none of which is a code typed into a
49
+ terminal.
50
+
51
+ npm's own helper does the same thing, but only when stdin AND stdout are
52
+ both terminals. A tool that pipes npm's output takes the terminal away from
53
+ it, and npm then gives up and prints a URL nobody can open. That is what
54
+ was happening, and it is why the command looked like it wanted a code from
55
+ an authenticator app and nothing else.
56
+ - The wait for a human is configurable with `OC_TRUST_WAIT_SECONDS`, so a CI
57
+ run does not hang on it.
58
+
17
59
  ## [0.3.7] — 2026-10-20
18
60
 
19
61
  ### Fixed
@@ -335,7 +377,9 @@ silent in the worst way:
335
377
 
336
378
  ---
337
379
 
338
- [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.7...HEAD
380
+ [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.9...HEAD
381
+ [0.3.9]: https://github.com/pham-canh/space-harness/compare/v0.3.8...v0.3.9
382
+ [0.3.8]: https://github.com/pham-canh/space-harness/compare/v0.3.7...v0.3.8
339
383
  [0.3.7]: https://github.com/pham-canh/space-harness/compare/v0.3.6...v0.3.7
340
384
  [0.3.6]: https://github.com/pham-canh/space-harness/compare/v0.3.5...v0.3.6
341
385
  [0.3.5]: https://github.com/pham-canh/space-harness/compare/v0.3.4...v0.3.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/space-harness#readme",
package/src/trust.js CHANGED
@@ -25,7 +25,7 @@
25
25
  // only runs from one directory is a release tool that people run from the
26
26
  // wrong one.
27
27
 
28
- import { execFileSync } from "node:child_process"
28
+ import { execFileSync, spawnSync } from "node:child_process"
29
29
  import { readFileSync } from "node:fs"
30
30
  import { dirname, join } from "node:path"
31
31
  import { fileURLToPath } from "node:url"
@@ -84,80 +84,118 @@ function npm(args, opts = {}) {
84
84
  }
85
85
 
86
86
  /**
87
- * Run npm, and if it asks for a one-time password, ask for one here and run
88
- * it again.
87
+ * Run npm with its output captured.
89
88
  *
90
- * npm's own helper for this opens a browser - but only when stdin and stdout
91
- * are both terminals, and under `npx` they are not. The result is a failure
92
- * that prints a web URL masked as `auth/cli/***`, which cannot be opened.
89
+ * @param {string[]} args
90
+ * @param {string[]} [extra] arguments to add, such as `--otp=<code>`
91
+ * @returns {{ out: string, code: number }}
92
+ */
93
+ function npmCaptured(args, extra = []) {
94
+ const r = spawnSync("npm", [...args, ...extra], {
95
+ encoding: "utf8",
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ })
98
+ return { out: `${r.stdout ?? ""}${r.stderr ?? ""}`, code: r.status ?? 1 }
99
+ }
100
+
101
+ /**
102
+ * Run npm, and complete a two-factor challenge if it asks for one.
93
103
  *
94
- * So the code is asked for here, and the same code is passed straight back to
95
- * npm as `--otp`.
104
+ * npm answers a request that needs a second factor with a body carrying the
105
+ * URL to authenticate at and the URL to collect the result from. The URL in
106
+ * the ERROR TEXT is masked - `auth/cli/***` - but the one in the JSON body is
107
+ * not, and that is the one that can be opened.
108
+ *
109
+ * So the challenge is completed the way npm completes it: the URL is opened
110
+ * in a browser, the user answers there however their account is set up - a
111
+ * code from an authenticator app, a security key, or one mailed to them - and
112
+ * the result is collected from the second URL.
113
+ *
114
+ * npm's own helper does exactly this, but only when stdin AND stdout are
115
+ * both terminals. A tool that pipes npm's output takes the terminal away from
116
+ * it, and npm then gives up and prints a URL nobody can open. So the flow is
117
+ * done here, where the URL can be recovered.
118
+ *
119
+ * EACH CALL GETS ITS OWN CHALLENGE. The code a browser flow returns is valid
120
+ * for the challenge that asked for it and for nothing else, so carrying one
121
+ * from one npm command to the next makes the second command send a code that
122
+ * is already spent, and the registry answers that with a different challenge
123
+ * - one that wants an authenticator code rather than a browser.
96
124
  *
97
125
  * @param {string[]} args
98
- * @param {{ otp?: string }} [state]
126
+ * @param {{ otp?: string }} [state] the user's own code, if they gave one
99
127
  * @returns {Promise<{ out: string, code: number }>}
100
128
  */
101
129
  async function npmWithOtp(args, state = {}) {
102
- /** @param {string[]} extra */
103
- const run = (extra) => {
130
+ const userOtp = state.otp ? [`--otp=${state.otp}`] : []
131
+ let result = npmCaptured(args, userOtp)
132
+ if (result.code === 0) return result
133
+
134
+ // The URLs come FROM the body, not from a guess. Guessing the done URL
135
+ // sends a real request to the registry with a made-up id, and the registry
136
+ // answers "forbidden" - which is not a token, so the wait runs its full
137
+ // length and then reports that nobody answered.
138
+ const done = /"doneUrl"\s*:\s*"([^"]+)"/.exec(result.out)
139
+ if (!done) return result
140
+ const doneUrl = done[1]
141
+ const auth = /"authUrl"\s*:\s*"([^"]+)"/.exec(result.out)
142
+ const authId = /authId=([a-f0-9-]+)/.exec(doneUrl)
143
+ const authUrl = auth ? auth[1] : `https://www.npmjs.com/auth/cli/${authId?.[1] ?? ""}`
144
+
145
+ // Open it if this machine can. The URL is printed either way, because a
146
+ // browser that cannot be opened here can be opened anywhere.
147
+ //
148
+ // OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
149
+ // a test that opens a browser on the machine it runs on is a test that
150
+ // breaks whatever the person at the keyboard was doing.
151
+ if (!process.env.OC_TRUST_NO_BROWSER) {
104
152
  try {
105
- return {
106
- out: execFileSync("npm", [...args, ...extra], {
107
- encoding: "utf8",
108
- stdio: ["ignore", "pipe", "pipe"],
109
- }),
110
- code: 0,
111
- }
112
- } catch (err) {
113
- const e = /** @type {any} */ (err)
114
- return { out: `${e.stdout ?? ""}${e.stderr ?? ""}`, code: e.status ?? 1 }
153
+ execFileSync("xdg-open", [authUrl], { stdio: "ignore", timeout: 10_000 })
154
+ } catch {
155
+ // No browser here. The URL below is the way out.
115
156
  }
116
157
  }
158
+ console.error("")
159
+ console.error(bold(" npm needs to confirm it is you."))
160
+ console.error(` ${authUrl}`)
161
+ console.error(dim(" Answer there however your npm account is set up - an authenticator"))
162
+ console.error(dim(" app, a security key, or a code sent to your email."))
163
+ console.error(dim(" This page is open in a browser, or copy the URL above."))
164
+ console.error("")
117
165
 
118
- const withOtp = state.otp ? [`--otp=${state.otp}`] : []
119
- let result = run(withOtp)
120
- if (result.code === 0 || !/one-time password|EOTP/i.test(result.out)) return result
121
-
122
- // npm wants a code. Get one.
123
- const code = state.otp ?? (await askForOtp())
124
- if (!code) {
125
- console.error(red("npm needs a one-time password and none was given."))
126
- console.error(dim(" run it again with --otp=<the code from your authenticator app>"))
127
- return { out: result.out, code: result.code }
166
+ const token = await waitForToken(doneUrl)
167
+ if (!token) {
168
+ console.error(red(" no answer came back, so nothing was changed."))
169
+ return result
128
170
  }
129
- state.otp = code
130
- result = run([`--otp=${code}`])
171
+ // The code is used for THIS call only.
172
+ result = npmCaptured(args, [`--otp=${token}`])
131
173
  return result
132
174
  }
133
175
 
134
176
  /**
135
- * Ask for the one-time password.
177
+ * Wait for the browser to answer.
136
178
  *
179
+ * @param {string} doneUrl
137
180
  * @returns {Promise<string | null>}
138
181
  */
139
- async function askForOtp() {
140
- if (!process.stdin.isTTY) {
141
- console.error(
142
- red("this needs a one-time password, and there is no terminal to ask for it on."),
143
- )
144
- console.error(dim(` run it with --otp=<the code from your authenticator app>`))
145
- return null
146
- }
147
- const { createInterface } = await import("node:readline/promises")
148
- // The prompt goes to stderr, and it gets a line of its own: a question
149
- // buried in the middle of npm's output is a question nobody answers.
150
- process.stderr.write("\n")
151
- const rl = createInterface({ input: process.stdin, output: process.stderr })
152
- try {
153
- const answer = (
154
- await rl.question(" one-time password from your authenticator app: ")
155
- ).trim()
156
- process.stderr.write("\n")
157
- return answer || null
158
- } finally {
159
- rl.close()
182
+ async function waitForToken(doneUrl) {
183
+ // How long to wait for a human. The window is generous, because the person
184
+ // may be answering an email, and it is configurable so a CI run does not
185
+ // hang on it.
186
+ const seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? 600)
187
+ const attempts = Math.max(1, Math.round(seconds / 5))
188
+ for (let i = 0; i < attempts; i++) {
189
+ try {
190
+ const res = await fetch(doneUrl)
191
+ const body = /** @type {any} */ (await res.json())
192
+ if (body && typeof body.token === "string" && body.token) return body.token
193
+ } catch {
194
+ // Not ready yet, or the network blipped. Try again.
195
+ }
196
+ await new Promise((r) => setTimeout(r, 5000))
160
197
  }
198
+ return null
161
199
  }
162
200
 
163
201
  /**