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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -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. Walk only
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; report what waits for them
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)). Ignore only noise;
346
- a region that matters to a criterion stays in.
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) or to an element of a live
76
- design step (`anchor`).
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.** Open the canvas link when a pin or an element
89
- matters; the position is on the screenshot of that viewport and locale.
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. Pixel diffs are computed in the browser;
185
- nothing is uploaded.
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
+ }
@@ -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'] })