kaloko 0.5.0 → 0.5.1
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/package.json +1 -1
- package/skills/kaloko/SKILL.md +4 -2
- package/skills/kaloko/reference/acceptance.md +4 -2
- package/skills/kaloko/reference/collaboration.md +35 -6
- package/skills/kaloko/reference/execution.md +9 -0
- package/skills/kaloko/reference/mcp.md +5 -2
- package/src/cli.mjs +40 -2
- package/src/lib/signoff-core.mjs +138 -0
- package/src/lib/signoff.mjs +83 -0
- package/src/lib/stability.mjs +82 -0
- package/viewer/index.html +458 -30
package/package.json
CHANGED
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -52,7 +52,8 @@ Start the shared browser, walk a scenario on a non-production environment,
|
|
|
52
52
|
create test data there (users, roles, carts), capture screens and e-mails,
|
|
53
53
|
evaluate, build the preview, and fix scenario mechanics (selectors, scripts)
|
|
54
54
|
that do not change what is being accepted, including applying a selector
|
|
55
|
-
proposal with `kaloko walk --heal` when it names the same element
|
|
55
|
+
proposal with `kaloko walk --heal` when it names the same element, and pulling
|
|
56
|
+
the regions people drew to ignore into the scenario (`kaloko stability --pull`). Walk only
|
|
56
57
|
the steps a change reaches (`--affected`) and import screenshots into a run. Record steps locally
|
|
57
58
|
(`walk --record`) when a screenshot cannot show what a person has to judge, read traces, and
|
|
58
59
|
export a walkthrough video into the run folder. On a design environment: change
|
|
@@ -73,7 +74,8 @@ a whole project (`capture.record` in the config: storage), a real clean-up
|
|
|
73
74
|
anything the environment profile blocks. Never trash an accepted run, a
|
|
74
75
|
baseline, a run with review comments, or a run linked from an open PR or issue
|
|
75
76
|
comment. Never approve a
|
|
76
|
-
step or accept a run on a person's behalf
|
|
77
|
+
step or accept a run on a person's behalf, and never hand out a signed
|
|
78
|
+
acceptance record (`kaloko signoff`) for a run a person has not accepted; report what waits for them
|
|
77
79
|
(`kaloko review`, MCP `get_review_queue`) instead. Ask the person
|
|
78
80
|
for secret values (passwords, tokens, gate credentials) and for sign-ins only
|
|
79
81
|
they can do; never write secrets into the config, a scenario or a script.
|
|
@@ -342,5 +342,7 @@ design pixel check (`AC801`) leave them out:
|
|
|
342
342
|
|
|
343
343
|
Do not guess them: `kaloko walk --stability 3` notes what moves while the
|
|
344
344
|
page sits still, and `kaloko stability` compares the step with earlier runs
|
|
345
|
-
and writes suggestions (see [execution.md](execution.md)).
|
|
346
|
-
a region
|
|
345
|
+
and writes suggestions (see [execution.md](execution.md)). Reviewers can also
|
|
346
|
+
draw a region on a screenshot on kaloko.app; `kaloko stability --pull` writes
|
|
347
|
+
those into `ignore:`. Ignore only noise; a region that matters to a criterion
|
|
348
|
+
stays in.
|
|
@@ -72,8 +72,11 @@ comments; at the end they accept or return the run.
|
|
|
72
72
|
Comments are threads in three places:
|
|
73
73
|
|
|
74
74
|
- **A step:** optionally pinned to a position on the screenshot
|
|
75
|
-
(`pin: {x, y}` as fractions, per viewport)
|
|
76
|
-
|
|
75
|
+
(`pin: {x, y}` as fractions, per viewport), drawn on it (a box, an arrow or
|
|
76
|
+
a line: `shape`, with `region` giving the area as fractions and in CSS
|
|
77
|
+
pixels of the page, and for an arrow the point it aims at), or attached to
|
|
78
|
+
an element of a live design step (`anchor`). Comments may carry images
|
|
79
|
+
(`attachments`, each with a download URL that needs the token).
|
|
77
80
|
- **The whole run:** `node_id` is `''`.
|
|
78
81
|
- **The whole flow:** these stay across runs, e.g. "add the empty-cart
|
|
79
82
|
state".
|
|
@@ -85,8 +88,12 @@ said what.
|
|
|
85
88
|
|
|
86
89
|
The loop, for every open thread:
|
|
87
90
|
|
|
88
|
-
1. **Understand it.**
|
|
89
|
-
|
|
91
|
+
1. **Understand it.** A `region` says exactly where to look: `px` is
|
|
92
|
+
`[x, y, width, height]` in CSS pixels of the captured page for that
|
|
93
|
+
viewport (`region.viewport`) and locale; find the element there in the
|
|
94
|
+
step's HTML or screenshot. Download attached images (a corrected copy, a
|
|
95
|
+
reference) before you change anything. Open the canvas link when a pin or
|
|
96
|
+
an element matters.
|
|
90
97
|
2. **Fix it** in the code or the scenario. When the comment asks for a
|
|
91
98
|
change of intent (criteria, scope), ask the person first.
|
|
92
99
|
3. **Answer and close.** Use
|
|
@@ -181,14 +188,25 @@ e-mail works too). Use it when a step needs a decision from a specific person
|
|
|
181
188
|
Put the relevant lines in the issue comment. On the canvas the same
|
|
182
189
|
comparison is the **Compare** selector in the top bar (or `#cmp=<run>` in
|
|
183
190
|
the URL): pixel differences per step, changed criteria, and a side-by-side
|
|
184
|
-
or diff view in the lightbox
|
|
185
|
-
|
|
191
|
+
or diff view in the lightbox, plus *Swipe* (a divider dragged across both
|
|
192
|
+
screenshots) and *Onion skin* (this run faded over the other). Pixel diffs
|
|
193
|
+
are computed in the browser; nothing is uploaded. **G** opens the grid: one
|
|
194
|
+
step in every locale and viewport at once, failing cells marked.
|
|
186
195
|
- **Manual criteria** are decided on the canvas by a reviewer; treat a
|
|
187
196
|
reviewer's `fail` like a failing deterministic check.
|
|
188
197
|
- Admins may name **required approvers** per scenario (Settings). Such a
|
|
189
198
|
run stays `pending` until every one of them approved; `kaloko feedback`
|
|
190
199
|
prints `awaiting approval from: …`, so do not treat a single approval as
|
|
191
200
|
acceptance there.
|
|
201
|
+
- An accepted run has a **signed acceptance record** (Business and up): the
|
|
202
|
+
run, scenario, commit and environment, who approved what and when (people
|
|
203
|
+
apart from tokens and agents, approvals carried over unchanged steps with
|
|
204
|
+
where they came from), criteria results, capture hashes and the hash of
|
|
205
|
+
`run.json`, signed with the service's Ed25519 key. `kaloko signoff <run>`
|
|
206
|
+
downloads the JSON and the PDF and checks them; `kaloko signoff --verify
|
|
207
|
+
<file>` checks a record someone handed over (public key at
|
|
208
|
+
`/.well-known/kaloko-signoff.json`). Give it to the person when they ask
|
|
209
|
+
for proof of acceptance; never create one for a run that is not accepted.
|
|
192
210
|
|
|
193
211
|
## Cleaning up runs
|
|
194
212
|
|
|
@@ -245,6 +263,17 @@ Everything above is available over the API with an organization token:
|
|
|
245
263
|
(the whole flow), and per comment `PATCH`, `DELETE`, `POST …/<cid>/resolve`
|
|
246
264
|
(with `note`) and `…/<cid>/reopen`; `GET /api/v1/orgs/<org>/comments?state=open|all&scenario=&run=&mine=1`
|
|
247
265
|
across the organization. Lists of runs and scenarios carry `open_comments`.
|
|
266
|
+
A comment body may carry `shape` (`{kind: box, box: [x, y, w, h]}`,
|
|
267
|
+
`{kind: arrow, from, to}` or `{kind: pen, points}` in fractions, with
|
|
268
|
+
`viewport` and `size`) and `attachments` (ids from
|
|
269
|
+
`POST …/comments/attachments?name=<file>` with the image as the body: PNG,
|
|
270
|
+
JPEG, WebP or GIF up to 5 MB, counted in the plan's storage).
|
|
271
|
+
- `GET|POST /api/v1/orgs/<org>/runs/<id>/ignore-regions`,
|
|
272
|
+
`DELETE …/ignore-regions/<rid>`, `GET …/scenarios/<id>/ignore-regions[?state=all]`
|
|
273
|
+
and `POST …/scenarios/<id>/ignore-regions/applied` (`{ids}`): regions drawn
|
|
274
|
+
to leave out of pixel diffs (drawing needs Developer / Tester).
|
|
275
|
+
- `GET /api/v1/orgs/<org>/runs/<id>/signoff` (JSON), `…/signoff.pdf`,
|
|
276
|
+
`…/signoff.html`: the signed acceptance record of an accepted run.
|
|
248
277
|
- `GET /api/v1/orgs/<org>/notifications[?unread=1]`, `POST` with
|
|
249
278
|
`{"read": "all"}` or `{"read": [ids]}`
|
|
250
279
|
- `GET /api/v1/orgs/<org>/runs[?project=&scenario=&kind=&verdict=&author=&environment=&branch=&q=][&latest=1]`
|
|
@@ -88,6 +88,7 @@ When pixel diffs between runs keep flagging the same clock, ad or carousel:
|
|
|
88
88
|
kaloko walk --stability 3 # 3 screenshots per capture, a moment apart; what moved is noted
|
|
89
89
|
kaloko stability # this run against the 3 previous local runs of the scenario
|
|
90
90
|
kaloko stability --apply # adds the suggested ignore: to each step (block-style YAML only)
|
|
91
|
+
kaloko stability --pull # regions people drew on screenshots on kaloko.app go into ignore:
|
|
91
92
|
```
|
|
92
93
|
|
|
93
94
|
`kaloko stability` writes `stability.json` and `stability.yml` into the run
|
|
@@ -98,6 +99,14 @@ changed is not noise. `--same-commit` compares only runs of the same commit,
|
|
|
98
99
|
`--runs a,b` names them, `--last N` sets how many runs are compared. Capture
|
|
99
100
|
the steps again after applying: the capture records the ignored regions.
|
|
100
101
|
|
|
102
|
+
People mark noise by hand too: a Developer / Tester draws a rectangle on a
|
|
103
|
+
screenshot (step detail → *Mark a changing region*). It is kept per flow, shows
|
|
104
|
+
dashed on every run and the canvas pixel diff already skips it.
|
|
105
|
+
`kaloko stability --pull` writes those regions into the scenario's `ignore:`
|
|
106
|
+
(appended to an existing block list, never twice) and tells the service they
|
|
107
|
+
are in the file; walk those steps again afterwards. MCP `list_ignore_regions`
|
|
108
|
+
lists them, `add_ignore_region` suggests one.
|
|
109
|
+
|
|
101
110
|
## Only what changed
|
|
102
111
|
|
|
103
112
|
After a fix, walk the steps the change reaches instead of the whole flow:
|
|
@@ -42,8 +42,11 @@ file.
|
|
|
42
42
|
|
|
43
43
|
| Tool | Use |
|
|
44
44
|
|---|---|
|
|
45
|
-
| `list_comments` | open threads (or `state: all`) across the organization, filtered by `scenario` or `run_id`, with replies, pins, anchors and who wrote them |
|
|
46
|
-
| `add_comment` | comment on a step (`run_id` + `step`), the whole run (`run_id`) or the whole flow (`scenario`); `parent_id` replies; optional `pin` `{ x, y, viewport }` |
|
|
45
|
+
| `list_comments` | open threads (or `state: all`) across the organization, filtered by `scenario` or `run_id`, with replies, pins, anchors, drawn regions (`region.px`: CSS pixels of the page in `region.viewport`), attached images with URLs, and who wrote them |
|
|
46
|
+
| `add_comment` | comment on a step (`run_id` + `step`), the whole run (`run_id`) or the whole flow (`scenario`); `parent_id` replies; optional `pin` `{ x, y, viewport }` or `shape` (box or arrow in fractions, with `viewport` and `size`) |
|
|
47
|
+
| `list_ignore_regions` | regions people drew to leave out of pixel diffs, per step, with the `ignore:` entry each becomes (`kaloko stability --pull` writes them) |
|
|
48
|
+
| `add_ignore_region` | suggest such a region (`run_id`, `step`, `viewport`, `rect` in CSS px); Developer / Tester, scope upload or full |
|
|
49
|
+
| `get_signoff_record` | the signed acceptance record of an accepted run (Business): approvals with provenance, criteria, capture hashes, signature |
|
|
47
50
|
| `resolve_comment` | close a thread with a `note` that says what changed (it becomes the last reply) |
|
|
48
51
|
| `reopen_comment`, `edit_comment`, `delete_comment` | reopen; edit or delete your own comment |
|
|
49
52
|
|
package/src/cli.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { configureGate } from './lib/linkcheck.mjs'
|
|
|
18
18
|
import { rememberEnvironment } from './lib/secrets.mjs'
|
|
19
19
|
import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
20
20
|
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
|
-
import { stabilityCmd } from './lib/stability.mjs'
|
|
21
|
+
import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
|
|
22
|
+
import { signoffCmd } from './lib/signoff.mjs'
|
|
22
23
|
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
|
|
23
24
|
import { packageRoot } from './lib/schema.mjs'
|
|
24
25
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
@@ -217,6 +218,15 @@ Results
|
|
|
217
218
|
Regions that change on every run or move while the page is idle (clocks, ads,
|
|
218
219
|
carousels): ignore suggestions per step in <run>/stability.yml; --apply adds
|
|
219
220
|
them to the scenario (ignore:), and pixel diffs then skip them
|
|
221
|
+
signoff <run-id> [--out dir] [--lang cs|en] [--json]
|
|
222
|
+
The signed acceptance record of an accepted run (Business): JSON and PDF with who
|
|
223
|
+
approved what and when, criteria results and capture hashes, checked against the
|
|
224
|
+
service's public key
|
|
225
|
+
signoff --verify <file.json|file.pdf> [--key file|url] [--json]
|
|
226
|
+
Check a record someone handed over (no token needed); exit 1 when it does not verify
|
|
227
|
+
stability --pull [--scenario id] [--json]
|
|
228
|
+
Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
|
|
229
|
+
region) go into the scenario's ignore: of their steps; walk them again after
|
|
220
230
|
compare [--run id] [--baseline id | --design] [--json]
|
|
221
231
|
Text diff of two runs: step statuses, criterion verdicts, scores and evidence
|
|
222
232
|
(default: current run against the scenario baseline, else the previous run)
|
|
@@ -257,6 +267,15 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
257
267
|
Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
|
|
258
268
|
`
|
|
259
269
|
|
|
270
|
+
/** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
|
|
271
|
+
export function regionText (g) {
|
|
272
|
+
if (!g) return ''
|
|
273
|
+
const pct = (v) => `${Math.round(v * 100)}%`
|
|
274
|
+
const where = g.px ? `${g.px[0]},${g.px[1]} ${g.px[2]}×${g.px[3]} px` : `${pct(g.box[0])},${pct(g.box[1])} ${pct(g.box[2])}×${pct(g.box[3])}`
|
|
275
|
+
const kind = g.kind === 'arrow' ? 'arrow over' : g.kind === 'pen' ? 'drawing over' : 'box'
|
|
276
|
+
return ` · ${kind} ${where}${g.viewport ? ` on ${g.viewport}` : ''}`
|
|
277
|
+
}
|
|
278
|
+
|
|
260
279
|
/** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
|
|
261
280
|
async function warnRetired (args) {
|
|
262
281
|
if (args.offline) return
|
|
@@ -1485,6 +1504,8 @@ const commands = {
|
|
|
1485
1504
|
|
|
1486
1505
|
async stability (args) {
|
|
1487
1506
|
const { config, root, run, scenario } = await runContext(args)
|
|
1507
|
+
// regions drawn by hand on the canvas (kaloko.app) into the scenario's ignore:
|
|
1508
|
+
if (args.pull) { await pullDrawn({ root, config, run, args, say, client: service(config) }); return }
|
|
1488
1509
|
await stabilityCmd({ root, config, run, scenario, args, say })
|
|
1489
1510
|
},
|
|
1490
1511
|
|
|
@@ -1685,7 +1706,8 @@ const commands = {
|
|
|
1685
1706
|
say(`Feedback for ${data.run.id} (${data.scenario}) — run verdict: ${data.verdict?.verdict || 'pending'}${data.verdict?.by ? ` by ${data.verdict.by}` : ''}${data.verdict?.note ? `: ${data.verdict.note}` : ''}${data.verdict?.awaiting?.length ? `\n awaiting approval from: ${data.verdict.awaiting.join(', ')}` : ''}${data.approved.length ? `\n approved: ${data.approved.join(', ')}` : ''}`)
|
|
1686
1707
|
for (const c of data.criterion_verdicts || []) say(` ✕ ${c.node_id} ${c.criterion_id}${c.locale ? ` [${c.locale}]` : ''} — ${c.email}: ${c.note}`)
|
|
1687
1708
|
const thread = (r, where) => {
|
|
1688
|
-
say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
|
|
1709
|
+
say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''}${regionText(r.region)} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
|
|
1710
|
+
for (const f of r.attachments || []) say(` attached ${f.name} (${f.type}): ${f.url || f.id}`)
|
|
1689
1711
|
for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
|
|
1690
1712
|
}
|
|
1691
1713
|
const flow = data.flow_comments || []
|
|
@@ -1708,6 +1730,22 @@ const commands = {
|
|
|
1708
1730
|
* accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
|
|
1709
1731
|
* agents: report it to the person; never approve on their behalf.
|
|
1710
1732
|
*/
|
|
1733
|
+
/**
|
|
1734
|
+
* The signed acceptance record of an accepted run: JSON and PDF downloaded and checked against the service's public
|
|
1735
|
+
* key; --verify checks a record someone handed over (no token, no project needed).
|
|
1736
|
+
*/
|
|
1737
|
+
async signoff (args) {
|
|
1738
|
+
if (typeof args.verify === 'string') {
|
|
1739
|
+
const config = await context(args).then((c) => c.config).catch(() => ({}))
|
|
1740
|
+
return signoffCmd({ config, args, say })
|
|
1741
|
+
}
|
|
1742
|
+
const { config, root } = await context(args)
|
|
1743
|
+
let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
|
|
1744
|
+
if (!runId) { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
|
|
1745
|
+
if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
|
|
1746
|
+
return signoffCmd({ config, args, say, client: service(config), runId })
|
|
1747
|
+
},
|
|
1748
|
+
|
|
1711
1749
|
async review (args) {
|
|
1712
1750
|
const { config, root } = await context(args)
|
|
1713
1751
|
const client = service(config)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Signed sign-off records (idea 10): canonical JSON, Ed25519 signatures and their verification. Shared by the service
|
|
2
|
+
// (worker/src/signoff.js) and the CLI (kaloko signoff): WebCrypto only, no node: imports, so it runs in both.
|
|
3
|
+
//
|
|
4
|
+
// What is signed: the record without its `signature` field, serialised as canonical JSON ("kaloko-c14n-v1": object
|
|
5
|
+
// keys sorted by UTF-16 code units at every level, no whitespace, undefined values left out, numbers and strings as
|
|
6
|
+
// JSON.stringify writes them). The signature is Ed25519 over the UTF-8 bytes of that text, base64url without padding.
|
|
7
|
+
|
|
8
|
+
const enc = new TextEncoder()
|
|
9
|
+
const dec = new TextDecoder()
|
|
10
|
+
|
|
11
|
+
export const CANONICAL = 'kaloko-c14n-v1'
|
|
12
|
+
export const SIGNOFF_SCHEMA = 'kaloko.signoff.v1'
|
|
13
|
+
export const PDF_ATTACHMENT = 'kaloko-signoff.json'
|
|
14
|
+
/** Marks the embedded file object in a sign-off PDF so the record can be found again without a PDF parser. */
|
|
15
|
+
export const PDF_MARK = '/KalokoSignoff true'
|
|
16
|
+
|
|
17
|
+
/** Canonical JSON text of a value (see the header). */
|
|
18
|
+
export function canonicalJson (value) {
|
|
19
|
+
if (value === null || typeof value !== 'object') {
|
|
20
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return 'null'
|
|
21
|
+
return JSON.stringify(value) ?? 'null'
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) return `[${value.map((v) => (v === undefined || typeof v === 'function' ? 'null' : canonicalJson(v))).join(',')}]`
|
|
24
|
+
if (typeof value.toJSON === 'function') return canonicalJson(value.toJSON())
|
|
25
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined && typeof value[k] !== 'function').sort()
|
|
26
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bytesOf = (x) => typeof x === 'string' ? enc.encode(x) : x instanceof Uint8Array ? x : new Uint8Array(x)
|
|
30
|
+
|
|
31
|
+
/** SHA-256 as lowercase hex of a string (UTF-8) or bytes. */
|
|
32
|
+
export async function sha256Hex (data) {
|
|
33
|
+
const h = new Uint8Array(await crypto.subtle.digest('SHA-256', bytesOf(data)))
|
|
34
|
+
return [...h].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** base64url without padding. */
|
|
38
|
+
export function b64url (bytes) {
|
|
39
|
+
const b = bytesOf(bytes); let s = ''
|
|
40
|
+
for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000))
|
|
41
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
42
|
+
}
|
|
43
|
+
/** Bytes of base64 or base64url (padding optional). */
|
|
44
|
+
export function fromB64url (text) {
|
|
45
|
+
const s = String(text).trim().replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '')
|
|
46
|
+
const bin = atob(s + '='.repeat((4 - (s.length % 4)) % 4))
|
|
47
|
+
const out = new Uint8Array(bin.length)
|
|
48
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PKCS8_SEED_PREFIX = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20])
|
|
53
|
+
const ED = { name: 'Ed25519' }
|
|
54
|
+
|
|
55
|
+
/** Key id: the first 16 hex characters of the SHA-256 of the raw public key. */
|
|
56
|
+
export const keyId = async (raw) => (await sha256Hex(raw)).slice(0, 16)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The service's signing key from the secret: a base64/base64url 32-byte Ed25519 seed, a base64 PKCS#8 DER, or a JWK
|
|
60
|
+
* (JSON with d and x). Returns { privateKey, publicJwk, publicRaw (base64url), kid }.
|
|
61
|
+
*/
|
|
62
|
+
export async function importSigningKey (secret) {
|
|
63
|
+
const text = String(secret || '').trim()
|
|
64
|
+
if (!text) throw new Error('No signing key.')
|
|
65
|
+
let privateKey
|
|
66
|
+
if (text.startsWith('{')) {
|
|
67
|
+
const jwk = JSON.parse(text)
|
|
68
|
+
privateKey = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', d: jwk.d, x: jwk.x, ext: true }, ED, true, ['sign'])
|
|
69
|
+
} else {
|
|
70
|
+
const raw = fromB64url(text)
|
|
71
|
+
let der
|
|
72
|
+
if (raw.length === 32) { der = new Uint8Array(48); der.set(PKCS8_SEED_PREFIX); der.set(raw, 16) } else der = raw
|
|
73
|
+
privateKey = await crypto.subtle.importKey('pkcs8', der, ED, true, ['sign'])
|
|
74
|
+
}
|
|
75
|
+
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
|
|
76
|
+
const publicRawBytes = fromB64url(jwk.x)
|
|
77
|
+
return { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, publicRaw: jwk.x, kid: await keyId(publicRawBytes) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const unsigned = (record) => { const { signature, ...rest } = record || {}; return rest }
|
|
81
|
+
|
|
82
|
+
/** Signs a record: { ...record, signature: { alg, kid, value, canonical } }. */
|
|
83
|
+
export async function signRecord (record, key) {
|
|
84
|
+
const body = unsigned(record)
|
|
85
|
+
const value = new Uint8Array(await crypto.subtle.sign(ED, key.privateKey, enc.encode(canonicalJson(body))))
|
|
86
|
+
return { ...body, signature: { alg: 'Ed25519', kid: key.kid, value: b64url(value), canonical: CANONICAL } }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** SHA-256 (hex) of the signed part of a record: what the PDF and the CLI print as the record hash. */
|
|
90
|
+
export const recordSha256 = (record) => sha256Hex(canonicalJson(unsigned(record)))
|
|
91
|
+
|
|
92
|
+
/** The public keys of a well-known document ({ keys: [...] }), a list, or one key object, as [{ kid, public_key }]. */
|
|
93
|
+
export function keyList (keys) {
|
|
94
|
+
const list = Array.isArray(keys) ? keys : Array.isArray(keys?.keys) ? keys.keys : keys ? [keys] : []
|
|
95
|
+
return list.map((k) => ({ kid: k.kid, public_key: k.public_key || k.x || k.jwk?.x })).filter((k) => k.public_key)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Verifies a signed record against public keys. Returns { ok, kid, reason }. */
|
|
99
|
+
export async function verifyRecord (signed, keys) {
|
|
100
|
+
const sig = signed?.signature
|
|
101
|
+
if (!sig || typeof sig !== 'object') return { ok: false, kid: null, reason: 'The record has no signature.' }
|
|
102
|
+
if (sig.alg !== 'Ed25519') return { ok: false, kid: sig.kid || null, reason: `Unknown signature algorithm ${sig.alg}.` }
|
|
103
|
+
if (sig.canonical && sig.canonical !== CANONICAL) return { ok: false, kid: sig.kid || null, reason: `Unknown canonical form ${sig.canonical}.` }
|
|
104
|
+
const list = keyList(keys)
|
|
105
|
+
const candidates = sig.kid ? list.filter((k) => !k.kid || k.kid === sig.kid) : list
|
|
106
|
+
if (!candidates.length) return { ok: false, kid: sig.kid || null, reason: `No public key with id ${sig.kid} (the service may have rotated its key).` }
|
|
107
|
+
let value
|
|
108
|
+
try { value = fromB64url(sig.value) } catch { return { ok: false, kid: sig.kid || null, reason: 'The signature is not base64url.' } }
|
|
109
|
+
const data = enc.encode(canonicalJson(unsigned(signed)))
|
|
110
|
+
for (const k of candidates) {
|
|
111
|
+
try {
|
|
112
|
+
const pub = await crypto.subtle.importKey('raw', fromB64url(k.public_key), ED, false, ['verify'])
|
|
113
|
+
if (await crypto.subtle.verify(ED, pub, value, data)) return { ok: true, kid: k.kid || sig.kid || null, reason: null }
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
return { ok: false, kid: sig.kid || null, reason: 'The signature does not match: the record was changed after it was signed, or it was signed with another key.' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reads bytes as latin1 text (one character per byte), for searching a PDF. */
|
|
120
|
+
const latin1 = (b) => { let s = ''; for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000)); return s }
|
|
121
|
+
|
|
122
|
+
/** The signed record embedded in a sign-off PDF (the attached kaloko-signoff.json), or null. */
|
|
123
|
+
export function extractRecordFromPdf (bytes) {
|
|
124
|
+
const b = bytesOf(bytes)
|
|
125
|
+
const text = latin1(b)
|
|
126
|
+
const at = text.lastIndexOf(PDF_MARK)
|
|
127
|
+
if (at < 0) return null
|
|
128
|
+
const dictStart = text.lastIndexOf('<<', at)
|
|
129
|
+
const dictEnd = text.indexOf('>>', at)
|
|
130
|
+
if (dictStart < 0 || dictEnd < 0) return null
|
|
131
|
+
const len = Number(text.slice(dictStart, dictEnd).match(/\/Length (\d+)/)?.[1])
|
|
132
|
+
const s = text.indexOf('stream', dictEnd)
|
|
133
|
+
if (!Number.isFinite(len) || s < 0) return null
|
|
134
|
+
let start = s + 'stream'.length
|
|
135
|
+
if (text[start] === '\r') start++
|
|
136
|
+
if (text[start] === '\n') start++
|
|
137
|
+
try { return JSON.parse(dec.decode(b.subarray(start, start + len))) } catch { return null }
|
|
138
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `kaloko signoff <run> [--out dir] [--lang cs|en] [--json]` downloads the signed acceptance record of an accepted run
|
|
2
|
+
// (JSON and PDF) and checks its signature; `kaloko signoff --verify <file.json|file.pdf> [--key file|url]` checks a
|
|
3
|
+
// record someone handed over, without a token. The keys come from <service>/.well-known/kaloko-signoff.json.
|
|
4
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { UserError } from './util.mjs'
|
|
7
|
+
import { serviceUrl } from './service.mjs'
|
|
8
|
+
import { verifyRecord, recordSha256, extractRecordFromPdf, canonicalJson } from './signoff-core.mjs'
|
|
9
|
+
|
|
10
|
+
/** Public keys from a file, a URL or the service's well-known document. */
|
|
11
|
+
export async function loadKeys ({ config, key }) {
|
|
12
|
+
const src = typeof key === 'string' && key ? key : `${serviceUrl(config || {})}/.well-known/kaloko-signoff.json`
|
|
13
|
+
try {
|
|
14
|
+
if (/^https?:\/\//.test(src)) {
|
|
15
|
+
const res = await fetch(src, { headers: { Accept: 'application/json' } })
|
|
16
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
17
|
+
return await res.json()
|
|
18
|
+
}
|
|
19
|
+
return JSON.parse(await readFile(resolve(src), 'utf8'))
|
|
20
|
+
} catch (e) { throw new UserError(`Could not read the public keys from ${src}: ${e.message}`) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The signed record in a .json or .pdf file. */
|
|
24
|
+
export async function readRecord (file) {
|
|
25
|
+
let bytes
|
|
26
|
+
try { bytes = await readFile(resolve(file)) } catch (e) { throw new UserError(`Cannot read ${file}: ${e.message}`) }
|
|
27
|
+
if (bytes.subarray(0, 5).toString('latin1') === '%PDF-') {
|
|
28
|
+
const record = extractRecordFromPdf(new Uint8Array(bytes))
|
|
29
|
+
if (!record) throw new UserError(`${file} has no Kaloko sign-off record attached.`)
|
|
30
|
+
return { record, kind: 'pdf' }
|
|
31
|
+
}
|
|
32
|
+
try { return { record: JSON.parse(bytes.toString('utf8')), kind: 'json' } } catch { throw new UserError(`${file} is neither a sign-off JSON nor a sign-off PDF.`) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Verification of one record: { ok, kid, reason, sha256, run, verdict }. */
|
|
36
|
+
export async function verifySignoff (record, keys) {
|
|
37
|
+
const v = await verifyRecord(record, keys)
|
|
38
|
+
return { ok: v.ok, kid: v.kid, reason: v.reason, sha256: await recordSha256(record), run: record?.run?.id || null, verdict: record?.verdict || null, issuer: record?.issuer?.service || null }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const summary = (r, check) => {
|
|
42
|
+
const v = r.verdict || {}; const a = r.approvals || {}; const s = r.summary || {}
|
|
43
|
+
const lines = [
|
|
44
|
+
`${r.run?.id} · ${r.run?.scenario?.title || r.run?.scenario?.id || ''} · ${r.run?.environment?.name || ''}${r.run?.git?.commit ? ` · ${String(r.run.git.commit).slice(0, 12)}` : ''}`,
|
|
45
|
+
` verdict: ${v.verdict} by ${v.by}${v.via && v.via !== 'session' ? ` (${v.via}, not a person's decision)` : ''} on ${String(v.at || '').slice(0, 16).replace('T', ' ')}`,
|
|
46
|
+
` approved in the app: ${(a.people || []).map((x) => x.email).join(', ') || 'nobody beyond the verdict'}${(a.tokens_and_agents || []).length ? `; by tokens or agents (not counted as people): ${a.tokens_and_agents.map((x) => x.email).join(', ')}` : ''}`,
|
|
47
|
+
` steps: ${s.steps ?? (r.steps || []).length} (${s.pass || 0} pass, ${s.partial || 0} partial, ${s.fail || 0} fail, ${s.skip || 0} not checked)${s.carried ? `, ${s.carried} approval(s) carried over unchanged steps` : ''}`,
|
|
48
|
+
` record SHA-256: ${check.sha256}`,
|
|
49
|
+
` run.json SHA-256: ${r.manifest?.sha256 || '—'}`,
|
|
50
|
+
check.ok ? ` ✓ signature valid (Ed25519, key ${check.kid}, ${check.issuer || 'unknown issuer'})` : ` ✕ signature NOT valid: ${check.reason}`
|
|
51
|
+
]
|
|
52
|
+
return lines.join('\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The command. `client` is service(config) (only for downloading); `runId` the run to download (a shared run id).
|
|
57
|
+
*/
|
|
58
|
+
export async function signoffCmd ({ config, args, say, client = null, runId = null }) {
|
|
59
|
+
if (typeof args.verify === 'string') {
|
|
60
|
+
const { record, kind } = await readRecord(args.verify)
|
|
61
|
+
const check = await verifySignoff(record, await loadKeys({ config, key: args.key }))
|
|
62
|
+
if (args.json) say(JSON.stringify({ file: args.verify, kind, ...check }, null, 2))
|
|
63
|
+
else say(summary(record, check))
|
|
64
|
+
if (!check.ok) throw new UserError(`The sign-off record in ${args.verify} does not verify: ${check.reason}`)
|
|
65
|
+
return check
|
|
66
|
+
}
|
|
67
|
+
if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] [--lang cs|en] | kaloko signoff --verify <file.json|file.pdf> [--key file|url]')
|
|
68
|
+
if (!client) throw new UserError('Downloading a sign-off record needs the service token (KALOKO_TOKEN).')
|
|
69
|
+
const base = `/orgs/${config.org}/runs/${encodeURIComponent(runId)}`
|
|
70
|
+
const lang = args.lang === 'cs' ? 'cs' : 'en'
|
|
71
|
+
const record = await client.request('GET', `${base}/signoff`)
|
|
72
|
+
const pdf = Buffer.from(await (await client.fetch(`${base}/signoff.pdf?lang=${lang}`)).arrayBuffer())
|
|
73
|
+
const dir = resolve(typeof args.out === 'string' ? args.out : '.')
|
|
74
|
+
await mkdir(dir, { recursive: true })
|
|
75
|
+
const jsonFile = join(dir, `kaloko-signoff-${runId}.json`); const pdfFile = join(dir, `kaloko-signoff-${runId}.pdf`)
|
|
76
|
+
await writeFile(jsonFile, canonicalJson(record))
|
|
77
|
+
await writeFile(pdfFile, pdf)
|
|
78
|
+
const check = await verifySignoff(record, await loadKeys({ config, key: args.key }))
|
|
79
|
+
if (args.json) say(JSON.stringify({ json: jsonFile, pdf: pdfFile, ...check }, null, 2))
|
|
80
|
+
else say(`${summary(record, check)}\nSaved ${jsonFile}\n ${pdfFile}\nAnyone can check it later: kaloko signoff --verify ${pdfFile}`)
|
|
81
|
+
if (!check.ok) throw new UserError(`The downloaded record does not verify: ${check.reason}`)
|
|
82
|
+
return check
|
|
83
|
+
}
|
package/src/lib/stability.mjs
CHANGED
|
@@ -227,6 +227,88 @@ export async function applySuggestions (file, report) {
|
|
|
227
227
|
return { applied, skipped }
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
const sameEntry = (a, b) => JSON.stringify({ rect: a.rect, viewport: a.viewport, selector: a.selector }) === JSON.stringify({ rect: b.rect, viewport: b.viewport, selector: b.selector })
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Adds ignore entries to steps of the scenario file, appending to an existing block-style `ignore:` list or creating
|
|
234
|
+
* one (comments and formatting stay; flow-style lists are left alone). `byNode`: Map node → entries.
|
|
235
|
+
* Returns { applied: [{ node, entries }], skipped: [reason] }; entries already in the file are not added twice.
|
|
236
|
+
*/
|
|
237
|
+
export async function mergeIgnore (file, byNode) {
|
|
238
|
+
let text = await readFile(file, 'utf8')
|
|
239
|
+
const applied = []; const skipped = []
|
|
240
|
+
for (const [nodeId, entries] of byNode) {
|
|
241
|
+
const parsed = yaml.load(text)
|
|
242
|
+
const node = (parsed?.nodes || []).find((n) => n.id === nodeId)
|
|
243
|
+
if (!node) { skipped.push(`${nodeId}: not in ${file}`); continue }
|
|
244
|
+
const fresh = entries.filter((e) => !(Array.isArray(node.ignore) ? node.ignore : []).some((x) => sameEntry(x, e)))
|
|
245
|
+
if (!fresh.length) { applied.push({ node: nodeId, entries: [] }); continue }
|
|
246
|
+
const lines = text.split('\n')
|
|
247
|
+
const at = lines.findIndex((l) => new RegExp(`^(\\s*)- id: ['"]?${nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*(#.*)?$`).test(l))
|
|
248
|
+
if (at < 0) { skipped.push(`${nodeId}: node is not in block style; add the regions by hand`); continue }
|
|
249
|
+
const dash = lines[at].match(/^(\s*)-/)[1].length
|
|
250
|
+
const indent = ' '.repeat(dash + 2)
|
|
251
|
+
// the node's block: lines indented deeper than its dash (blank lines included)
|
|
252
|
+
let end = at + 1
|
|
253
|
+
while (end < lines.length && (!lines[end].trim() || lines[end].match(/^(\s*)/)[1].length > dash)) end++
|
|
254
|
+
while (end > at + 1 && !lines[end - 1].trim()) end--
|
|
255
|
+
const key = lines.slice(at + 1, end).findIndex((l) => l.startsWith(`${indent}ignore:`))
|
|
256
|
+
const item = (e, pad) => `${pad}- ${yaml.dump(e, { flowLevel: 0, lineWidth: 400 }).trim()}`
|
|
257
|
+
let block
|
|
258
|
+
if (key < 0) {
|
|
259
|
+
block = { at: end, lines: [`${indent}ignore:`, ...fresh.map((e) => item(e, indent + ' '))] }
|
|
260
|
+
} else {
|
|
261
|
+
const k = at + 1 + key
|
|
262
|
+
if (lines[k].replace(/#.*$/, '').trim() !== 'ignore:') { skipped.push(`${nodeId}: ignore is written inline; add the regions by hand`); continue }
|
|
263
|
+
// list items follow at the key's indentation or deeper; the list ends at the next line that is neither
|
|
264
|
+
let j = k + 1; let pad = null
|
|
265
|
+
while (j < end) {
|
|
266
|
+
const l = lines[j]
|
|
267
|
+
if (!l.trim()) { j++; continue }
|
|
268
|
+
const ind = l.match(/^(\s*)/)[1].length
|
|
269
|
+
if (pad == null) { if (!l.trimStart().startsWith('- ') || ind < indent.length) break; pad = ' '.repeat(ind) }
|
|
270
|
+
if (ind < pad.length || (ind === pad.length && !l.trimStart().startsWith('- '))) break
|
|
271
|
+
j++
|
|
272
|
+
}
|
|
273
|
+
while (j > k + 1 && !lines[j - 1].trim()) j--
|
|
274
|
+
block = { at: j, lines: fresh.map((e) => item(e, pad ?? indent + ' ')) }
|
|
275
|
+
}
|
|
276
|
+
const next = [...lines.slice(0, block.at), ...block.lines, ...lines.slice(block.at)].join('\n')
|
|
277
|
+
let ok = false
|
|
278
|
+
try { const n2 = (yaml.load(next)?.nodes || []).find((n) => n.id === nodeId); ok = Array.isArray(n2?.ignore) && n2.ignore.length === (Array.isArray(node.ignore) ? node.ignore.length : 0) + fresh.length } catch {}
|
|
279
|
+
if (!ok) { skipped.push(`${nodeId}: could not add to its ignore list safely; add the regions by hand`); continue }
|
|
280
|
+
text = next
|
|
281
|
+
applied.push({ node: nodeId, entries: fresh })
|
|
282
|
+
}
|
|
283
|
+
if (applied.some((a) => a.entries.length)) await writeFile(file, text)
|
|
284
|
+
return { applied, skipped }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `kaloko stability --pull`: regions people drew on the canvas (Developer / Tester and up) become ignore: entries of
|
|
289
|
+
* their steps in the scenario file; the service then stops showing them as suggestions.
|
|
290
|
+
*/
|
|
291
|
+
export async function pullDrawn ({ root, config, run, args, say, client }) {
|
|
292
|
+
const scenarioId = typeof args.scenario === 'string' ? args.scenario : run.meta.scenario.id
|
|
293
|
+
const data = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/ignore-regions`)
|
|
294
|
+
const regions = data.regions || []
|
|
295
|
+
if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Page → Draw a region).`); return { applied: [], skipped: [], regions: [] } }
|
|
296
|
+
const byNode = new Map()
|
|
297
|
+
for (const r of regions) (byNode.get(r.node_id) || byNode.set(r.node_id, []).get(r.node_id)).push(r.scenario_entry)
|
|
298
|
+
const file = resolve(root, run.meta.scenario_file)
|
|
299
|
+
const out = await mergeIgnore(file, byNode)
|
|
300
|
+
const done = new Set(out.applied.map((a) => a.node))
|
|
301
|
+
const ids = regions.filter((r) => done.has(r.node_id)).map((r) => r.id)
|
|
302
|
+
if (ids.length) await client.request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/ignore-regions/applied`, { ids })
|
|
303
|
+
if (args.json) { say(JSON.stringify({ ...out, regions }, null, 2)); return out }
|
|
304
|
+
const added = out.applied.reduce((n, a) => n + a.entries.length, 0)
|
|
305
|
+
say(`Pulled ${regions.length} drawn region(s) of ${scenarioId}: ${added} added to ${run.meta.scenario_file}${added < ids.length ? `, ${ids.length - added} already there` : ''}.`)
|
|
306
|
+
for (const a of out.applied.filter((x) => x.entries.length)) say(` ${a.node}: ${a.entries.map((e) => `[${e.rect.join(', ')}] on ${e.viewport}`).join('; ')}`)
|
|
307
|
+
for (const x of out.skipped) say(` skipped ${x}`)
|
|
308
|
+
if (added) say('Walk those steps again so the canvas and pixel diffs use them.')
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
|
|
230
312
|
/** `kaloko stability`: detect, write stability.json and stability.yml in the run, print, optionally apply. */
|
|
231
313
|
export async function stabilityCmd ({ root, config, run, scenario, args, say }) {
|
|
232
314
|
const report = await detectUnstable({ root, config, run, scenario, last: Number(args.last) || 4, runs: typeof args.runs === 'string' ? args.runs.split(',').map((s) => s.trim()).filter(Boolean) : null, sameCommit: !!args['same-commit'] })
|