opencode-harness-kit 0.3.7 → 0.3.8

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,29 @@ is not happening.
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.3.8] — 2026-10-20
18
+
19
+ ### Fixed
20
+
21
+ - **`oc-harness trust` completes the two-factor challenge the way npm does, so
22
+ it works with whatever second factor the account has.** npm answers a
23
+ request that needs one with the URL to authenticate at and the URL to
24
+ collect the result from - the URL in the error text is masked, but the one
25
+ in the JSON body is not.
26
+
27
+ The URL is opened in a browser and printed, the result is collected, and the
28
+ command carries on. That works with an authenticator app, a security key,
29
+ or a code sent to the user's email - none of which is a code typed into a
30
+ terminal.
31
+
32
+ npm's own helper does the same thing, but only when stdin AND stdout are
33
+ both terminals. A tool that pipes npm's output takes the terminal away from
34
+ it, and npm then gives up and prints a URL nobody can open. That is what
35
+ was happening, and it is why the command looked like it wanted a code from
36
+ an authenticator app and nothing else.
37
+ - The wait for a human is configurable with `OC_TRUST_WAIT_SECONDS`, so a CI
38
+ run does not hang on it.
39
+
17
40
  ## [0.3.7] — 2026-10-20
18
41
 
19
42
  ### Fixed
@@ -335,7 +358,8 @@ silent in the worst way:
335
358
 
336
359
  ---
337
360
 
338
- [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.7...HEAD
361
+ [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.3.8...HEAD
362
+ [0.3.8]: https://github.com/pham-canh/space-harness/compare/v0.3.7...v0.3.8
339
363
  [0.3.7]: https://github.com/pham-canh/space-harness/compare/v0.3.6...v0.3.7
340
364
  [0.3.6]: https://github.com/pham-canh/space-harness/compare/v0.3.5...v0.3.6
341
365
  [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.8",
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,101 @@ 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 {{ otp?: string }} [state]
91
+ * @returns {{ out: string, code: number }}
92
+ */
93
+ function npmCaptured(args, state = {}) {
94
+ const extra = state.otp ? [`--otp=${state.otp}`] : []
95
+ const r = spawnSync("npm", [...args, ...extra], {
96
+ encoding: "utf8",
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ })
99
+ return { out: `${r.stdout ?? ""}${r.stderr ?? ""}`, code: r.status ?? 1 }
100
+ }
101
+
102
+ /**
103
+ * Run npm, and complete a two-factor challenge if it asks for one.
93
104
  *
94
- * So the code is asked for here, and the same code is passed straight back to
95
- * npm as `--otp`.
105
+ * npm answers a request that needs a second factor with a body carrying the
106
+ * URL to authenticate at and the URL to collect the result from. The URL in
107
+ * the ERROR TEXT is masked - `auth/cli/***` - but the one in the JSON body is
108
+ * not, and that is the one that can be opened.
109
+ *
110
+ * So the challenge is completed the way npm completes it: the URL is opened
111
+ * in a browser, the user answers there however their account is set up - a
112
+ * code from an authenticator app, a security key, or one mailed to them - and
113
+ * the result is collected from the second URL.
114
+ *
115
+ * npm's own helper does exactly this, but only when stdin AND stdout are
116
+ * both terminals. A tool that pipes npm's output takes the terminal away from
117
+ * it, and npm then gives up and prints a URL nobody can open. So the flow is
118
+ * done here, where the URL can be recovered.
96
119
  *
97
120
  * @param {string[]} args
98
121
  * @param {{ otp?: string }} [state]
99
122
  * @returns {Promise<{ out: string, code: number }>}
100
123
  */
101
124
  async function npmWithOtp(args, state = {}) {
102
- /** @param {string[]} extra */
103
- const run = (extra) => {
104
- 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 }
115
- }
116
- }
125
+ let result = npmCaptured(args, state)
126
+ if (result.code === 0) return result
127
+
128
+ const m = /"doneUrl"\s*:\s*"[^"]*authId=([a-f0-9-]+)"/.exec(result.out)
129
+ if (!m) return result
130
+ const authId = m[1]
131
+ const authUrl = `https://www.npmjs.com/auth/cli/${authId}`
132
+ const doneUrl = `https://registry.npmjs.org/-/v1/done?authId=${authId}`
117
133
 
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
134
+ // Open it if this machine can. The URL is printed either way, because a
135
+ // browser that cannot be opened here can be opened anywhere.
136
+ try {
137
+ execFileSync("xdg-open", [authUrl], { stdio: "ignore", timeout: 10_000 })
138
+ } catch {
139
+ // No browser here. The URL below is the way out.
140
+ }
141
+ console.error("")
142
+ console.error(bold(" npm needs to confirm it is you."))
143
+ console.error(` ${authUrl}`)
144
+ console.error(dim(" Answer there however your npm account is set up - an authenticator"))
145
+ console.error(dim(" app, a security key, or a code sent to your email."))
146
+ console.error(dim(" This page is open in a browser, or copy the URL above."))
147
+ console.error("")
121
148
 
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 }
149
+ const token = await waitForToken(doneUrl)
150
+ if (!token) {
151
+ console.error(red(" no answer came back, so nothing was changed."))
152
+ return result
128
153
  }
129
- state.otp = code
130
- result = run([`--otp=${code}`])
154
+ state.otp = token
155
+ result = npmCaptured(args, state)
131
156
  return result
132
157
  }
133
158
 
134
159
  /**
135
- * Ask for the one-time password.
160
+ * Wait for the browser to answer.
136
161
  *
162
+ * @param {string} doneUrl
137
163
  * @returns {Promise<string | null>}
138
164
  */
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()
165
+ async function waitForToken(doneUrl) {
166
+ // How long to wait for a human. The window is generous, because the person
167
+ // may be answering an email, and it is configurable so a CI run does not
168
+ // hang on it.
169
+ const seconds = Number(process.env.OC_TRUST_WAIT_SECONDS ?? 600)
170
+ const attempts = Math.max(1, Math.round(seconds / 5))
171
+ for (let i = 0; i < attempts; i++) {
172
+ try {
173
+ const res = await fetch(doneUrl)
174
+ const body = /** @type {any} */ (await res.json())
175
+ if (body && typeof body.token === "string" && body.token) return body.token
176
+ } catch {
177
+ // Not ready yet, or the network blipped. Try again.
178
+ }
179
+ await new Promise((r) => setTimeout(r, 5000))
160
180
  }
181
+ return null
161
182
  }
162
183
 
163
184
  /**