opencode-harness-kit 0.4.3 → 0.4.4
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 +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/src/tasks.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the goal, the constraints, the decisions, any prose a human added -- survives
|
|
7
7
|
// a rewrite untouched.
|
|
8
8
|
|
|
9
|
-
export const TASK_FIELDS = ["files", "change", "verify", "status"]
|
|
9
|
+
export const TASK_FIELDS = ["files", "change", "verify", "status", "output"]
|
|
10
10
|
|
|
11
11
|
const SECTIONS = ["Constraints", "Decisions", "Risks", "Tasks"]
|
|
12
12
|
|
|
@@ -17,12 +17,14 @@ const fieldRe = new RegExp(`^\\s+-\\s+(${TASK_FIELDS.join("|")})\\s*:\\s*(.*)$`)
|
|
|
17
17
|
/**
|
|
18
18
|
* @typedef {object} Task
|
|
19
19
|
* @property {number} index position in the file, zero based
|
|
20
|
+
* @property {number} ordinal position among the tasks with the same title
|
|
20
21
|
* @property {boolean} checked the checkbox was ticked
|
|
21
22
|
* @property {string} title the task on one line
|
|
22
23
|
* @property {string} files which files it is allowed to touch
|
|
23
24
|
* @property {string} change what it is supposed to change
|
|
24
25
|
* @property {string} verify the command that proves it
|
|
25
26
|
* @property {string} status pending | done | blocked
|
|
27
|
+
* @property {string} output the output that command actually produced
|
|
26
28
|
* @property {number} start first line of the task block
|
|
27
29
|
* @property {number} end last line of the task block
|
|
28
30
|
* @property {string} [key] indexed by field name when a block is written
|
|
@@ -74,21 +76,31 @@ export function parseTasks(text) {
|
|
|
74
76
|
|
|
75
77
|
/** @type {Task[]} */
|
|
76
78
|
const tasks = []
|
|
79
|
+
// How many tasks with the same title have been seen. A driver that has to
|
|
80
|
+
// find its task again after the file changed underneath it needs a way to
|
|
81
|
+
// tell two identically-named tasks apart.
|
|
82
|
+
const titleCounts = new Map()
|
|
77
83
|
for (let i = 0; i < lines.length; i++) {
|
|
78
84
|
const m = lines[i].match(taskRe)
|
|
79
85
|
if (!m) continue
|
|
80
86
|
// A checkbox outside the Tasks section is prose, not a task.
|
|
81
87
|
if (!inSection(lines, i, "Tasks")) continue
|
|
82
88
|
|
|
89
|
+
const title = m[2].trim()
|
|
90
|
+
const ordinal = titleCounts.get(title) ?? 0
|
|
91
|
+
titleCounts.set(title, ordinal + 1)
|
|
92
|
+
|
|
83
93
|
/** @type {Record<string, any> & Task} */
|
|
84
94
|
const t = {
|
|
85
95
|
index: tasks.length,
|
|
96
|
+
ordinal,
|
|
86
97
|
checked: m[1].toLowerCase() === "x",
|
|
87
|
-
title
|
|
98
|
+
title,
|
|
88
99
|
files: "",
|
|
89
100
|
change: "",
|
|
90
101
|
verify: "",
|
|
91
102
|
status: "",
|
|
103
|
+
output: "",
|
|
92
104
|
start: i,
|
|
93
105
|
end: i,
|
|
94
106
|
}
|
|
@@ -104,8 +116,17 @@ export function parseTasks(text) {
|
|
|
104
116
|
continue
|
|
105
117
|
}
|
|
106
118
|
// A wrapped value continues the field above it, indented deeper.
|
|
119
|
+
//
|
|
120
|
+
// Under `verify` it cannot be the command. A continuation of the
|
|
121
|
+
// verify line is the output of that command, and folding it back into
|
|
122
|
+
// the field made the driver hand "npm test exit 0 in 3s" to a shell.
|
|
123
|
+
// It is collected as output instead, where it cannot be executed.
|
|
107
124
|
if (last && lines[j].trim() && /^\s{4,}\S/.test(lines[j])) {
|
|
108
|
-
|
|
125
|
+
if (last === "verify") {
|
|
126
|
+
t.output = t.output ? `${t.output} ${lines[j].trim()}` : lines[j].trim()
|
|
127
|
+
} else {
|
|
128
|
+
t[last] = `${t[last]} ${lines[j].trim()}`.trim()
|
|
129
|
+
}
|
|
109
130
|
continue
|
|
110
131
|
}
|
|
111
132
|
if (!lines[j].trim()) continue
|
|
@@ -165,11 +186,14 @@ export const isDone = (t) => t.checked || t.status === "done"
|
|
|
165
186
|
export const isBlocked = (t) => t.status === "blocked"
|
|
166
187
|
|
|
167
188
|
/**
|
|
168
|
-
* Record the outcome of a task: flip the checkbox, set the status, and
|
|
169
|
-
* verify output that was actually observed.
|
|
189
|
+
* Record the outcome of a task: flip the checkbox, set the status, and record
|
|
190
|
+
* the verify output that was actually observed.
|
|
170
191
|
*
|
|
171
|
-
* The
|
|
172
|
-
*
|
|
192
|
+
* The output goes into its own `output:` field rather than under the `verify:`
|
|
193
|
+
* line. It used to be written as continuation lines under `verify:`, and the
|
|
194
|
+
* parser folded a continuation back into the field above it - so the command
|
|
195
|
+
* and its output became one string, and the driver handed that string to a
|
|
196
|
+
* shell. A separate field cannot be mistaken for a command.
|
|
173
197
|
*
|
|
174
198
|
* @param {string} text
|
|
175
199
|
* @param {Task} task
|
|
@@ -179,9 +203,11 @@ export const isBlocked = (t) => t.status === "blocked"
|
|
|
179
203
|
export function markTask(text, task, { status, verifyOutput }) {
|
|
180
204
|
const lines = String(text).split("\n")
|
|
181
205
|
const boxLine = lines[task.start] ?? ""
|
|
206
|
+
// The indentation is part of the document: a task nested under a heading
|
|
207
|
+
// keeps its nesting when it is marked.
|
|
182
208
|
lines[task.start] = boxLine.replace(
|
|
183
|
-
|
|
184
|
-
status === "done" ? "
|
|
209
|
+
/^(\s*)-\s*\[[ xX]\]/,
|
|
210
|
+
(_match, indent) => `${indent}- ${status === "done" ? "[x]" : "[ ]"}`,
|
|
185
211
|
)
|
|
186
212
|
|
|
187
213
|
let statusLine = -1
|
|
@@ -198,28 +224,76 @@ export function markTask(text, task, { status, verifyOutput }) {
|
|
|
198
224
|
)
|
|
199
225
|
|
|
200
226
|
if (verifyOutput) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
227
|
+
// A task that is marked twice keeps one output block, not two.
|
|
228
|
+
const previous = outputBlock(lines, task)
|
|
229
|
+
if (previous) lines.splice(previous.start, previous.end - previous.start + 1)
|
|
230
|
+
|
|
231
|
+
const verifyLine = fieldLine(lines, task, "verify")
|
|
232
|
+
const anchor = verifyLine >= 0 ? verifyLine : lastFieldLine(lines, task)
|
|
233
|
+
const source = lines[anchor] ?? lines[task.start] ?? ""
|
|
234
|
+
// The field line sits at the same level as the field it belongs to; only
|
|
235
|
+
// the continuation lines under it are indented deeper.
|
|
236
|
+
const fieldIndent = (source.match(/^\s*/) ?? [""])[0]
|
|
237
|
+
const contIndent = `${fieldIndent} `
|
|
238
|
+
const quoted = String(verifyOutput)
|
|
239
|
+
.trimEnd()
|
|
240
|
+
.split("\n")
|
|
241
|
+
.map((l) => `${contIndent}${l}`)
|
|
242
|
+
// The first line rides on the field, the rest are continuations of it.
|
|
243
|
+
const block = [`${fieldIndent}- output: ${quoted[0].trim()}`, ...quoted.slice(1)]
|
|
244
|
+
lines.splice(anchor + 1, 0, ...block)
|
|
218
245
|
}
|
|
219
246
|
|
|
220
247
|
return lines.join("\n")
|
|
221
248
|
}
|
|
222
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The line a named field sits on, or -1.
|
|
252
|
+
* @param {string[]} lines
|
|
253
|
+
* @param {Task} task
|
|
254
|
+
* @param {string} name
|
|
255
|
+
* @returns {number}
|
|
256
|
+
*/
|
|
257
|
+
function fieldLine(lines, task, name) {
|
|
258
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
259
|
+
const f = lines[i]?.match(fieldRe)
|
|
260
|
+
if (f && f[1] === name) return i
|
|
261
|
+
}
|
|
262
|
+
return -1
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The last field line of a task, or the task's own line when it has none.
|
|
267
|
+
* @param {string[]} lines
|
|
268
|
+
* @param {Task} task
|
|
269
|
+
* @returns {number}
|
|
270
|
+
*/
|
|
271
|
+
function lastFieldLine(lines, task) {
|
|
272
|
+
let last = task.start
|
|
273
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
274
|
+
if (fieldRe.test(lines[i] ?? "")) last = i
|
|
275
|
+
}
|
|
276
|
+
return last
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The lines a task's `output:` field occupies, or null when it has none.
|
|
281
|
+
* @param {string[]} lines
|
|
282
|
+
* @param {Task} task
|
|
283
|
+
* @returns {{ start: number, end: number } | null}
|
|
284
|
+
*/
|
|
285
|
+
function outputBlock(lines, task) {
|
|
286
|
+
const start = fieldLine(lines, task, "output")
|
|
287
|
+
if (start < 0) return null
|
|
288
|
+
let end = start
|
|
289
|
+
for (let i = start + 1; i <= task.end + 1 && i < lines.length; i++) {
|
|
290
|
+
if (fieldRe.test(lines[i] ?? "")) break
|
|
291
|
+
if (lines[i].trim() && !/^\s{4,}\S/.test(lines[i])) break
|
|
292
|
+
end = i
|
|
293
|
+
}
|
|
294
|
+
return { start, end }
|
|
295
|
+
}
|
|
296
|
+
|
|
223
297
|
/**
|
|
224
298
|
* One task rendered as the prompt handed to the agent that will do it.
|
|
225
299
|
*
|
|
@@ -237,12 +311,21 @@ export function taskPrompt(task) {
|
|
|
237
311
|
/**
|
|
238
312
|
* The summary the driver prints when it stops, whatever stopped it.
|
|
239
313
|
*
|
|
314
|
+
* Every task is counted exactly once. A task that is ticked and also marked
|
|
315
|
+
* blocked is a contradiction a human can leave in the file, and counting it
|
|
316
|
+
* in both buckets made the three counters add up to more than the file holds.
|
|
317
|
+
*
|
|
240
318
|
* @param {TaskFile} parsed
|
|
241
319
|
* @returns {{ done: number, blocked: number, pending: number, total: number }}
|
|
242
320
|
*/
|
|
243
321
|
export function summarize(parsed) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
322
|
+
let done = 0
|
|
323
|
+
let blocked = 0
|
|
324
|
+
let pending = 0
|
|
325
|
+
for (const t of parsed.tasks) {
|
|
326
|
+
if (isDone(t)) done++
|
|
327
|
+
else if (isBlocked(t)) blocked++
|
|
328
|
+
else pending++
|
|
329
|
+
}
|
|
247
330
|
return { done, blocked, pending, total: parsed.tasks.length }
|
|
248
331
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The telemetry file, and the one thing that writes to it.
|
|
2
|
+
//
|
|
3
|
+
// It lives in its own module because TWO plugins write to it: the telemetry
|
|
4
|
+
// plugin, and the guard.
|
|
5
|
+
//
|
|
6
|
+
// The guard has to write to it because a denial never reaches
|
|
7
|
+
// `tool.execute.after`. The guard denies by throwing in `tool.execute.before`,
|
|
8
|
+
// and opencode calls `tool.execute.after` only after the tool has run - so a
|
|
9
|
+
// blocked command produces no `after` event at all. The record of a denial
|
|
10
|
+
// has to be written where the denial happens, or not at all.
|
|
11
|
+
//
|
|
12
|
+
// The file is a JSONL in the project, gitignored, and never a network call.
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
|
|
17
|
+
/** Rotate above this, so a long-running project cannot fill its disk. */
|
|
18
|
+
export const MAX_BYTES = 4 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
export function logPath(dir: string): string {
|
|
21
|
+
return join(dir, "telemetry.jsonl")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The telemetry file and its rotated predecessors, oldest first. */
|
|
25
|
+
export function logFiles(dir: string): string[] {
|
|
26
|
+
const out: string[] = []
|
|
27
|
+
for (const suffix of [".2", ".1", ""]) {
|
|
28
|
+
const f = `${logPath(dir)}${suffix}`
|
|
29
|
+
if (existsSync(f)) out.push(f)
|
|
30
|
+
}
|
|
31
|
+
// Oldest first: `.2` was rotated twice, `.1` once, and the plain file is
|
|
32
|
+
// the newest.
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rotate by size so a long-running project cannot fill its disk. */
|
|
37
|
+
function rotate(file: string) {
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(file) && statSync(file).size > MAX_BYTES) {
|
|
40
|
+
renameSync(file, `${file}.1`)
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Best effort.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Append one record.
|
|
49
|
+
*
|
|
50
|
+
* Nothing here is allowed to throw. Telemetry must never be load-bearing: a
|
|
51
|
+
* full disk or a read-only project must not break a turn.
|
|
52
|
+
*/
|
|
53
|
+
export function write(dir: string, record: Record<string, unknown>) {
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
56
|
+
const file = logPath(dir)
|
|
57
|
+
rotate(file)
|
|
58
|
+
appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + "\n")
|
|
59
|
+
} catch {
|
|
60
|
+
// Telemetry must never be load-bearing.
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/telemetry.ts
CHANGED
|
@@ -31,41 +31,31 @@
|
|
|
31
31
|
// OpenCode 1.19, its `agent` field is undefined, so it cannot attribute a
|
|
32
32
|
// call to a lane; and a second counter that cannot be correlated with the
|
|
33
33
|
// first would double-count every call.
|
|
34
|
+
//
|
|
35
|
+
// WHAT A DENIAL LOOKS LIKE HERE
|
|
36
|
+
//
|
|
37
|
+
// A denial does NOT reach this plugin. The guard denies by throwing in
|
|
38
|
+
// `tool.execute.before`, and opencode calls `tool.execute.after` only after
|
|
39
|
+
// the tool has run - so a blocked command produces no `after` event. The
|
|
40
|
+
// record of a denial is written by the guard itself, into the same file.
|
|
41
|
+
//
|
|
42
|
+
// The `kind: "guard"` record that used to live here was a false positive: it
|
|
43
|
+
// scraped the word "blocked:" out of the OUTPUT of whatever tool ran, so a
|
|
44
|
+
// `read` of a source file that happened to contain the word produced a record
|
|
45
|
+
// claiming a rule had fired.
|
|
34
46
|
|
|
35
|
-
import type { Plugin } from "
|
|
36
|
-
import { appendFileSync, mkdirSync, existsSync, statSync, renameSync } from "node:fs"
|
|
47
|
+
import type { Plugin } from "./plugin.js"
|
|
37
48
|
import { join } from "node:path"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (existsSync(file) && statSync(file).size > MAX_BYTES) {
|
|
49
|
-
renameSync(file, `${file}.1`)
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
// Best effort.
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function write(dir: string, record: Record<string, unknown>) {
|
|
57
|
-
try {
|
|
58
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
59
|
-
const file = logPath(dir)
|
|
60
|
-
rotate(file)
|
|
61
|
-
appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + "\n")
|
|
62
|
-
} catch {
|
|
63
|
-
// Telemetry must never be load-bearing.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
68
|
-
const dir = join(worktree, ".space")
|
|
49
|
+
import { write } from "./telemetry-store.js"
|
|
50
|
+
import { projectRoot } from "./project.js"
|
|
51
|
+
import { safeStringify } from "./safe.js"
|
|
52
|
+
import { ASK_GATED } from "./rules.js"
|
|
53
|
+
|
|
54
|
+
export const TelemetryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
55
|
+
// The project root, not the worktree: the worktree is the filesystem root
|
|
56
|
+
// in a project that is not a git repository, so the telemetry was written
|
|
57
|
+
// somewhere nobody looks, or nowhere at all.
|
|
58
|
+
const dir = join(projectRoot(directory, worktree), ".space")
|
|
69
59
|
const started = Date.now()
|
|
70
60
|
|
|
71
61
|
// The message id of every lane record already written, and of every one
|
|
@@ -80,7 +70,7 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
|
80
70
|
const closed = new Set<string>()
|
|
81
71
|
|
|
82
72
|
return {
|
|
83
|
-
event: async ({ event }) => {
|
|
73
|
+
event: async ({ event }: any) => {
|
|
84
74
|
// Events are provider-specific and opencode may add shapes we have never
|
|
85
75
|
// seen. Anything unrecognised is ignored rather than trusted.
|
|
86
76
|
const type = (event as { type?: unknown } | undefined)?.type
|
|
@@ -88,7 +78,11 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
|
88
78
|
|
|
89
79
|
// Provider rate limits are the one signal that changes behaviour, so
|
|
90
80
|
// they get logged even though the event shape is provider-specific.
|
|
91
|
-
|
|
81
|
+
//
|
|
82
|
+
// The match is on whole tokens rather than on substrings, so an
|
|
83
|
+
// opencode event whose name happens to contain a letter of "rate" is
|
|
84
|
+
// not filed as a provider event and drowns the real ones.
|
|
85
|
+
if (/(^|[._-])(error|errors|rate|limit|limits|quota)([._-]|$)/i.test(type)) {
|
|
92
86
|
write(dir, { kind: "provider-event", type, detail: truncate(safeStringify(event)) })
|
|
93
87
|
return
|
|
94
88
|
}
|
|
@@ -117,15 +111,19 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
|
|
|
117
111
|
}
|
|
118
112
|
},
|
|
119
113
|
|
|
120
|
-
"tool.execute.after": async (input, output) => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
"tool.execute.after": async (input: any, output: any) => {
|
|
115
|
+
// A command the guard has a rule about, and that the guard let through.
|
|
116
|
+
//
|
|
117
|
+
// The list is `ASK_GATED` in rules.js, not a keyword list here: the
|
|
118
|
+
// guard's policy about what it does not block lives in one place, and a
|
|
119
|
+
// second copy of it would drift.
|
|
120
|
+
//
|
|
121
|
+
// This is the opposite of a denial, and the name says so. A record
|
|
122
|
+
// called "guard-attempt" that only ever contained ALLOWED commands was
|
|
123
|
+
// describing the wrong thing entirely.
|
|
126
124
|
const command = String((input.args as { command?: unknown })?.command ?? "")
|
|
127
|
-
if (input.tool === "bash" &&
|
|
128
|
-
write(dir, { kind: "guard-
|
|
125
|
+
if (input.tool === "bash" && ASK_GATED.some((p) => p.test(command))) {
|
|
126
|
+
write(dir, { kind: "guard-allowed", tool: input.tool, command: truncate(command) })
|
|
129
127
|
}
|
|
130
128
|
},
|
|
131
129
|
|
|
@@ -150,6 +148,10 @@ function laneOf(event: unknown): {
|
|
|
150
148
|
model?: string
|
|
151
149
|
provider?: string
|
|
152
150
|
ms?: number
|
|
151
|
+
/** The context that request carried, in tokens. */
|
|
152
|
+
ctx?: number
|
|
153
|
+
/** The reasoning that request spent, in tokens. */
|
|
154
|
+
reasoning?: number
|
|
153
155
|
} | null {
|
|
154
156
|
const e = event as { properties?: any } | undefined
|
|
155
157
|
const info = e?.properties?.info ?? e?.properties
|
|
@@ -166,31 +168,32 @@ function laneOf(event: unknown): {
|
|
|
166
168
|
const ms = created !== undefined && completed !== undefined ? completed - created : undefined
|
|
167
169
|
|
|
168
170
|
if (!id || !agent) return null
|
|
171
|
+
// The context the request carried, and the reasoning it spent.
|
|
172
|
+
//
|
|
173
|
+
// Without these, "which lanes are worth keeping" can only be answered on
|
|
174
|
+
// FREQUENCY - a lane used once a week looks the same as a lane used once a
|
|
175
|
+
// week that costs a million tokens. The two answers are different questions
|
|
176
|
+
// and the second one is the expensive one.
|
|
177
|
+
const tokens = (info as any).tokens ?? {}
|
|
178
|
+
const ctx =
|
|
179
|
+
typeof tokens.input === "number"
|
|
180
|
+
? tokens.input + (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0)
|
|
181
|
+
: undefined
|
|
182
|
+
const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : undefined
|
|
183
|
+
|
|
169
184
|
return {
|
|
170
185
|
id,
|
|
171
186
|
agent,
|
|
172
187
|
model: typeof modelId === "string" ? modelId : undefined,
|
|
173
188
|
provider: typeof providerId === "string" ? providerId : undefined,
|
|
174
189
|
ms: typeof ms === "number" && ms >= 0 ? ms : undefined,
|
|
190
|
+
ctx: typeof ctx === "number" ? ctx : undefined,
|
|
191
|
+
reasoning,
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
function extractRule(output: string): string {
|
|
179
|
-
const m = output.match(/blocked:\s*([^\n]+)/)
|
|
180
|
-
return m ? m[1].trim() : "unknown"
|
|
181
|
-
}
|
|
182
|
-
|
|
183
195
|
function truncate(s: string, n = 300): string {
|
|
184
196
|
return s.length > n ? `${s.slice(0, n)}...` : s
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
/** Stringify without throwing on a cycle or a BigInt. */
|
|
188
|
-
function safeStringify(value: unknown): string {
|
|
189
|
-
try {
|
|
190
|
-
return JSON.stringify(value) ?? String(value)
|
|
191
|
-
} catch {
|
|
192
|
-
return String(value)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
199
|
export default TelemetryPlugin
|
package/src/trust.js
CHANGED
|
@@ -62,6 +62,37 @@ export function repoOf(repository) {
|
|
|
62
62
|
return `${m[1]}/${m[2]}`
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Open a URL in the user's browser.
|
|
67
|
+
*
|
|
68
|
+
* `xdg-open` is the Linux opener. macOS uses `open` and Windows uses
|
|
69
|
+
* `rundll32`, so on two of the three platforms this kit claims to support
|
|
70
|
+
* the browser simply never opened - and the failure was swallowed, so the
|
|
71
|
+
* message afterwards still said the page was open.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} url
|
|
74
|
+
* @returns {boolean} whether a browser was actually opened
|
|
75
|
+
*/
|
|
76
|
+
export function openBrowser(url) {
|
|
77
|
+
/** @type {Array<[string, string[]]>} */
|
|
78
|
+
const candidates =
|
|
79
|
+
process.platform === "darwin"
|
|
80
|
+
? [["open", [url]]]
|
|
81
|
+
: process.platform === "win32"
|
|
82
|
+
? [["rundll32", ["url.dll,FileProtocolHandler", url]]]
|
|
83
|
+
: [["xdg-open", [url]], ["gio", ["open", url]], ["gnome-open", [url]]]
|
|
84
|
+
for (const [bin, args] of candidates) {
|
|
85
|
+
try {
|
|
86
|
+
execFileSync(bin, args, { stdio: "ignore", timeout: 10_000 })
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
// Try the next opener. The URL is printed either way, because a
|
|
90
|
+
// browser that cannot be opened here can be opened anywhere.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
/**
|
|
66
97
|
* Run npm and return its output.
|
|
67
98
|
*
|
|
@@ -153,19 +184,24 @@ async function npmWithOtp(args, state = {}) {
|
|
|
153
184
|
// OC_TRUST_NO_BROWSER turns the opening off, which is what a test needs:
|
|
154
185
|
// a test that opens a browser on the machine it runs on is a test that
|
|
155
186
|
// breaks whatever the person at the keyboard was doing.
|
|
187
|
+
let opened = false
|
|
156
188
|
if (!process.env.OC_TRUST_NO_BROWSER) {
|
|
157
|
-
|
|
158
|
-
execFileSync("xdg-open", [authUrl], { stdio: "ignore", timeout: 10_000 })
|
|
159
|
-
} catch {
|
|
160
|
-
// No browser here. The URL below is the way out.
|
|
161
|
-
}
|
|
189
|
+
opened = openBrowser(authUrl)
|
|
162
190
|
}
|
|
163
191
|
console.error("")
|
|
164
192
|
console.error(bold(" npm needs to confirm it is you."))
|
|
165
193
|
console.error(` ${authUrl}`)
|
|
166
194
|
console.error(dim(" Answer there however your npm account is set up - an authenticator"))
|
|
167
195
|
console.error(dim(" app, a security key, or a code sent to your email."))
|
|
168
|
-
|
|
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
|
+
)
|
|
169
205
|
console.error("")
|
|
170
206
|
|
|
171
207
|
const token = await waitForToken(doneUrl)
|
|
@@ -270,10 +306,15 @@ export async function main(argv = []) {
|
|
|
270
306
|
console.error(" npm install -g npm@latest")
|
|
271
307
|
return 1
|
|
272
308
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
309
|
+
// Whether the account is logged in. `npm whoami` is run captured rather
|
|
310
|
+
// than through `npm()`, because `npm()` ends the process on a failure - so
|
|
311
|
+
// the guidance below was unreachable and the user saw only npm's raw
|
|
312
|
+
// error text.
|
|
313
|
+
const who = npmCaptured(["whoami"])
|
|
314
|
+
if (who.code !== 0) {
|
|
276
315
|
console.error(red("not logged in to the npm registry."))
|
|
316
|
+
const said = who.out.trim().split("\n").filter(Boolean).slice(-2).join(" ")
|
|
317
|
+
if (said) console.error(dim(` npm said: ${said}`))
|
|
277
318
|
console.error(" npm login")
|
|
278
319
|
return 1
|
|
279
320
|
}
|
|
@@ -379,6 +420,11 @@ export async function main(argv = []) {
|
|
|
379
420
|
console.error(" revoke it on npmjs.com and run this again.")
|
|
380
421
|
return 1
|
|
381
422
|
}
|
|
423
|
+
// What is being replaced, kept so the failure below can say how to put
|
|
424
|
+
// it back. npm cannot edit a configuration, and a revoke that is not
|
|
425
|
+
// followed by a create leaves the package with NO trusted publisher at
|
|
426
|
+
// all - strictly worse than before this command ran.
|
|
427
|
+
const previous = describe(config)
|
|
382
428
|
const revoked = await npmWithOtp(["trust", "revoke", name, "--id", id, "-y"], state)
|
|
383
429
|
if (revoked.code !== 0) {
|
|
384
430
|
console.error(red("npm did not revoke the old configuration, so nothing was replaced."))
|
|
@@ -401,6 +447,21 @@ export async function main(argv = []) {
|
|
|
401
447
|
)
|
|
402
448
|
if (created.code !== 0) {
|
|
403
449
|
console.error(red("npm did not create the replacement configuration."))
|
|
450
|
+
// The old configuration is gone by now. Say so plainly, and say what
|
|
451
|
+
// the old one was, so the package is not left with nothing and nobody
|
|
452
|
+
// knowing why.
|
|
453
|
+
console.error(
|
|
454
|
+
red(" The old configuration has already been revoked, so this package has no trusted publisher now."),
|
|
455
|
+
)
|
|
456
|
+
console.error(
|
|
457
|
+
dim(` It was: repository ${previous.repository} · file ${previous.file} · no environment`),
|
|
458
|
+
)
|
|
459
|
+
console.error(dim(" Put it back with:"))
|
|
460
|
+
console.error(
|
|
461
|
+
dim(
|
|
462
|
+
` npm trust github ${name} --file ${previous.file} --repo ${previous.repository} --allow-publish --allow-stage-publish -y`,
|
|
463
|
+
),
|
|
464
|
+
)
|
|
404
465
|
const detail = created.out.trim()
|
|
405
466
|
if (detail) {
|
|
406
467
|
console.error(dim(" npm said:"))
|