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 +45 -1
- package/package.json +1 -1
- package/src/trust.js +93 -55
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.
|
|
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.
|
|
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
|
|
88
|
-
* it again.
|
|
87
|
+
* Run npm with its output captured.
|
|
89
88
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
*
|
|
95
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
130
|
-
result =
|
|
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
|
-
*
|
|
177
|
+
* Wait for the browser to answer.
|
|
136
178
|
*
|
|
179
|
+
* @param {string} doneUrl
|
|
137
180
|
* @returns {Promise<string | null>}
|
|
138
181
|
*/
|
|
139
|
-
async function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
/**
|