@ijfw/memory-server 1.4.4 → 1.5.0
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/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +1 -1
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +550 -14
- package/src/cross-orchestrator.js +1016 -17
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +5 -1
- package/src/dashboard-server.js +73 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +26 -2
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +201 -6
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +554 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +152 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/obsidian-parser.js +91 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +10 -0
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +249 -0
- package/src/orchestrator/review.js +38 -3
- package/src/orchestrator/runtime-loop.js +430 -0
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1764 -0
- package/src/orchestrator/status-protocol.js +84 -17
- package/src/orchestrator/subagent-telemetry.js +452 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +200 -16
- package/src/orchestrator/wave-state.js +332 -23
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +656 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -0
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +603 -59
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +94 -17
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +41 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +41 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +278 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// playwright-baseline.js -- v1.5.0 audit-MED-design-#6.
|
|
2
|
+
//
|
|
3
|
+
// Visual-regression baseline for the ijfw-ui-auditor pipeline. Stores
|
|
4
|
+
// reference screenshots under `.planning/visual-baseline/<phase>/<surface>.png`
|
|
5
|
+
// and compares subsequent snapshots against them.
|
|
6
|
+
//
|
|
7
|
+
// Playwright is treated as an optional peer. IJFW core stays zero-dep, so this
|
|
8
|
+
// file works in three modes:
|
|
9
|
+
//
|
|
10
|
+
// 1. capture(): caller passes a Buffer (PNG bytes). We just write it to disk
|
|
11
|
+
// with atomic rename. No Playwright needed.
|
|
12
|
+
//
|
|
13
|
+
// 2. compare(): caller passes the new Buffer (or path). We byte-diff against
|
|
14
|
+
// baseline. Returns {diffPercent: 0|100, status: 'identical'|'changed'|...}
|
|
15
|
+
// when no perceptual diff peer is installed. If `opts.pixelmatch` is
|
|
16
|
+
// injected (callers can require pixelmatch + pngjs themselves), we use the
|
|
17
|
+
// real per-pixel ratio.
|
|
18
|
+
//
|
|
19
|
+
// 3. createBaseline()/compareToBaseline(): high-level helpers used by the
|
|
20
|
+
// auditor agent. Auto-derive the baseline path from {phase, surface}.
|
|
21
|
+
//
|
|
22
|
+
// Graceful no-op without Playwright: createBaseline() simply records "no
|
|
23
|
+
// snapshot supplied -- baseline deferred"; compareToBaseline() returns
|
|
24
|
+
// {pass: null, reason: 'no-snapshot'} so the auditor can mark FLAG instead of
|
|
25
|
+
// hard-failing.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
} from 'node:fs';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_BASELINE_ROOT = '.planning/visual-baseline';
|
|
39
|
+
const DEFAULT_DIFF_THRESHOLD = 1.0; // % of differing pixels above which fail
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the baseline path for a (phase, surface) pair.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* @param {string} opts.phase
|
|
46
|
+
* @param {string} opts.surface
|
|
47
|
+
* @param {string} [opts.root] default .planning/visual-baseline
|
|
48
|
+
* @param {string} [opts.projectRoot] default cwd
|
|
49
|
+
*/
|
|
50
|
+
export function baselinePath(opts) {
|
|
51
|
+
const phase = sanitizeSegment(opts.phase || 'unspecified');
|
|
52
|
+
const surface = sanitizeSegment(opts.surface || 'default');
|
|
53
|
+
const root = opts.root || DEFAULT_BASELINE_ROOT;
|
|
54
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
55
|
+
return join(projectRoot, root, phase, `${surface}.png`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write a baseline screenshot. Caller is responsible for capturing the bytes
|
|
60
|
+
* (e.g. via `await page.screenshot()` in Playwright).
|
|
61
|
+
*
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {Buffer|null} opts.png PNG bytes; null = "Playwright unavailable, skip"
|
|
64
|
+
* @param {string} opts.phase
|
|
65
|
+
* @param {string} opts.surface
|
|
66
|
+
* @param {string} [opts.root]
|
|
67
|
+
* @param {string} [opts.projectRoot]
|
|
68
|
+
* @returns {{ok: boolean, path: string|null, reason: string}}
|
|
69
|
+
*/
|
|
70
|
+
export function createBaseline(opts) {
|
|
71
|
+
const target = baselinePath(opts);
|
|
72
|
+
if (!opts.png || !(opts.png instanceof Uint8Array)) {
|
|
73
|
+
return { ok: false, path: target, reason: 'no-snapshot' };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(dirname(target))) mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
|
|
77
|
+
// Binary-safe atomic write: tmp file + rename. We can't use writeAtomic
|
|
78
|
+
// from lib/atomic-io.js because it JSON-stringifies non-string payloads.
|
|
79
|
+
const tmp = `${target}.tmp.${randomBytes(6).toString('hex')}`;
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(tmp, opts.png, { mode: 0o644 });
|
|
82
|
+
renameSync(tmp, target);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
try { unlinkSync(tmp); } catch { /* */ }
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, path: target, reason: 'baseline-written' };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: false, path: target, reason: `write-failed: ${e.code || e.message}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compare a candidate PNG against the stored baseline.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} opts
|
|
97
|
+
* @param {Buffer|null} opts.png Candidate bytes.
|
|
98
|
+
* @param {string} opts.phase
|
|
99
|
+
* @param {string} opts.surface
|
|
100
|
+
* @param {string} [opts.root]
|
|
101
|
+
* @param {string} [opts.projectRoot]
|
|
102
|
+
* @param {number} [opts.threshold] Allowed diff %; default 1.0.
|
|
103
|
+
* @param {Function} [opts.pixelmatch] Optional injected pixelmatch fn.
|
|
104
|
+
* @param {Function} [opts.pngParser] Optional injected PNG parser (e.g. require('pngjs').PNG.sync.read)
|
|
105
|
+
* @returns {{pass: boolean|null, diffPercent: number|null, baselinePath: string, reason: string}}
|
|
106
|
+
*/
|
|
107
|
+
export function compareToBaseline(opts) {
|
|
108
|
+
const baseline = baselinePath(opts);
|
|
109
|
+
const threshold = typeof opts.threshold === 'number' ? opts.threshold : DEFAULT_DIFF_THRESHOLD;
|
|
110
|
+
|
|
111
|
+
if (!opts.png || !(opts.png instanceof Uint8Array)) {
|
|
112
|
+
return { pass: null, diffPercent: null, baselinePath: baseline, reason: 'no-snapshot' };
|
|
113
|
+
}
|
|
114
|
+
if (!existsSync(baseline)) {
|
|
115
|
+
return { pass: null, diffPercent: null, baselinePath: baseline, reason: 'baseline-missing' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let baselineBytes;
|
|
119
|
+
try {
|
|
120
|
+
baselineBytes = readFileSync(baseline);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { pass: null, diffPercent: null, baselinePath: baseline, reason: `baseline-read-failed: ${e.code || e.message}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Fast path: byte-identical.
|
|
126
|
+
if (bytesEqual(baselineBytes, opts.png)) {
|
|
127
|
+
return { pass: true, diffPercent: 0, baselinePath: baseline, reason: 'identical' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Hash path: hashes differ → at least 1 byte differs → 100% naive diff.
|
|
131
|
+
// Unless a real per-pixel differ is injected.
|
|
132
|
+
if (typeof opts.pixelmatch === 'function' && typeof opts.pngParser === 'function') {
|
|
133
|
+
try {
|
|
134
|
+
const baseImg = opts.pngParser(baselineBytes);
|
|
135
|
+
const candImg = opts.pngParser(opts.png);
|
|
136
|
+
if (baseImg.width !== candImg.width || baseImg.height !== candImg.height) {
|
|
137
|
+
return {
|
|
138
|
+
pass: false,
|
|
139
|
+
diffPercent: 100,
|
|
140
|
+
baselinePath: baseline,
|
|
141
|
+
reason: `dimension-mismatch: ${baseImg.width}x${baseImg.height} vs ${candImg.width}x${candImg.height}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const diffBuffer = new Uint8Array(baseImg.data.length);
|
|
145
|
+
const diffPixels = opts.pixelmatch(
|
|
146
|
+
baseImg.data,
|
|
147
|
+
candImg.data,
|
|
148
|
+
diffBuffer,
|
|
149
|
+
baseImg.width,
|
|
150
|
+
baseImg.height,
|
|
151
|
+
{ threshold: 0.1 },
|
|
152
|
+
);
|
|
153
|
+
const total = baseImg.width * baseImg.height;
|
|
154
|
+
const diffPercent = total === 0 ? 0 : (diffPixels / total) * 100;
|
|
155
|
+
const pass = diffPercent <= threshold;
|
|
156
|
+
return {
|
|
157
|
+
pass,
|
|
158
|
+
diffPercent: Math.round(diffPercent * 100) / 100,
|
|
159
|
+
baselinePath: baseline,
|
|
160
|
+
reason: pass ? 'within-threshold' : `diff ${diffPercent.toFixed(2)}% > threshold ${threshold}%`,
|
|
161
|
+
};
|
|
162
|
+
} catch {
|
|
163
|
+
// Fall through to hash-based fallback.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Hash fallback.
|
|
168
|
+
const baseHash = createHash('sha256').update(baselineBytes).digest('hex');
|
|
169
|
+
const candHash = createHash('sha256').update(opts.png).digest('hex');
|
|
170
|
+
return {
|
|
171
|
+
pass: false,
|
|
172
|
+
diffPercent: 100,
|
|
173
|
+
baselinePath: baseline,
|
|
174
|
+
reason: `hash-mismatch (no per-pixel differ installed): ${baseHash.slice(0, 8)} vs ${candHash.slice(0, 8)}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function bytesEqual(a, b) {
|
|
179
|
+
if (a.length !== b.length) return false;
|
|
180
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
181
|
+
if (a[i] !== b[i]) return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sanitizeSegment(s) {
|
|
187
|
+
return String(s)
|
|
188
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
189
|
+
.replace(/^-+|-+$/g, '')
|
|
190
|
+
.slice(0, 80) || 'unspecified';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the auditor prompt fragment for the snapshot capture step.
|
|
195
|
+
*/
|
|
196
|
+
export function playwrightCapturePromptFor(url, phase, surface) {
|
|
197
|
+
return [
|
|
198
|
+
'Playwright (optional peer): if installed, run',
|
|
199
|
+
" npx playwright screenshot " + JSON.stringify(url) + " /tmp/<surface>.png --full-page",
|
|
200
|
+
`Then call createBaseline({ phase: '${phase}', surface: '${surface}', png: <bytes> })`,
|
|
201
|
+
'from mcp-server/src/lib/playwright-baseline.js.',
|
|
202
|
+
'If Playwright is missing, leave the baseline unset; the auditor will FLAG',
|
|
203
|
+
'rather than BLOCK on missing-snapshot.',
|
|
204
|
+
].join('\n');
|
|
205
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rekor-bridge.js — IJFW v1.5.0 audit-H5.7 Sigstore Rekor transparency log.
|
|
3
|
+
*
|
|
4
|
+
* Closes the meta-key-compromise gap from v1.4.1. When the optional
|
|
5
|
+
* `@sigstore/rekor` peer dep is present (or `IJFW_REKOR_URL` is set for a
|
|
6
|
+
* self-hosted Rekor instance), the registry signer pushes the
|
|
7
|
+
* `{payload, signature, publicKey}` triple to Rekor's append-only public
|
|
8
|
+
* transparency log on sign. Downstream verifiers cross-check the registry's
|
|
9
|
+
* embedded Rekor entry against the live log on verify — an attacker who
|
|
10
|
+
* swaps the meta-key cannot backdate a Rekor entry, so the swap is detectable.
|
|
11
|
+
*
|
|
12
|
+
* Three principles:
|
|
13
|
+
* 1. Graceful no-op. If the peer dep is missing, every function returns null
|
|
14
|
+
* or false in a way the caller can ignore. Ed25519 signature verification
|
|
15
|
+
* remains the primary trust check.
|
|
16
|
+
* 2. Never throw. submitToRekor and verifyRekorEntry catch all errors,
|
|
17
|
+
* emit a stderr advisory, and return null. The caller decides whether
|
|
18
|
+
* to proceed.
|
|
19
|
+
* 3. Backcompat. Unsigned-by-Rekor registries (signed before this lift) still
|
|
20
|
+
* verify on the Ed25519 check alone — the cross-check fires only when
|
|
21
|
+
* both a `rekor` field is embedded AND a local Rekor client is available.
|
|
22
|
+
*
|
|
23
|
+
* Threat model (v1.4.1 → v1.5.0):
|
|
24
|
+
* v1.4.1 shipped Ed25519 publisher signing + meta-key rotation. But if an
|
|
25
|
+
* attacker compromises the meta-key, downstream installs cannot detect the
|
|
26
|
+
* swap — there is no append-only third-party witness. Rekor provides that
|
|
27
|
+
* witness: every legitimate registry sign is also pushed to a public log,
|
|
28
|
+
* so an attacker who later swaps the meta-key would have to either
|
|
29
|
+
* (a) push a tampered registry to Rekor with an entry that doesn't
|
|
30
|
+
* match any prior entry — clients see the registry's rekor field
|
|
31
|
+
* contains a uuid that resolves to a payload not matching the
|
|
32
|
+
* served registry, OR
|
|
33
|
+
* (b) try to backdate Rekor entries, which is cryptographically
|
|
34
|
+
* impossible (Merkle tree append-only).
|
|
35
|
+
*
|
|
36
|
+
* @see https://docs.sigstore.dev/rekor/overview/
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Client cache: avoid re-importing the peer dep on every call.
|
|
43
|
+
// __setRekorClientForTest replaces the cache for unit tests; resolveClient
|
|
44
|
+
// honors the test override before attempting the real import.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
let _cachedClient = undefined; // undefined = not-yet-probed, null = unavailable
|
|
48
|
+
let _testClient = null; // explicit test-only override
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Test-only seam: inject a stub Rekor client.
|
|
52
|
+
*
|
|
53
|
+
* The stub must implement:
|
|
54
|
+
* - createEntry({ payload, signature, publicKey }) → Promise<{ uuid, logIndex, integratedTime }>
|
|
55
|
+
* - getEntry({ uuid }) → Promise<{ payloadHash: string }>
|
|
56
|
+
*
|
|
57
|
+
* Where `payloadHash` is the sha256-hex of the payload that was originally
|
|
58
|
+
* submitted to Rekor (so verifyRekorEntry can compare hashes without re-fetching
|
|
59
|
+
* the entire payload from the log).
|
|
60
|
+
*
|
|
61
|
+
* Pass `null` to clear the override and resume normal probe behavior.
|
|
62
|
+
*
|
|
63
|
+
* @param {object|null} stub
|
|
64
|
+
*/
|
|
65
|
+
export function __setRekorClientForTest(stub) {
|
|
66
|
+
_testClient = stub;
|
|
67
|
+
// Reset cache so the next call re-resolves through the test override.
|
|
68
|
+
_cachedClient = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a Rekor client, preferring (in order):
|
|
73
|
+
* 1. The explicit test override (`__setRekorClientForTest`).
|
|
74
|
+
* 2. A dynamic import of `@sigstore/rekor` if it's installed as a peer dep.
|
|
75
|
+
* 3. null when neither is available.
|
|
76
|
+
*
|
|
77
|
+
* The result is cached for the process lifetime (test overrides bust the cache).
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<object|null>}
|
|
80
|
+
*/
|
|
81
|
+
async function resolveClient() {
|
|
82
|
+
if (_testClient !== null) return _testClient;
|
|
83
|
+
if (_cachedClient !== undefined) return _cachedClient;
|
|
84
|
+
try {
|
|
85
|
+
const mod = await import('@sigstore/rekor');
|
|
86
|
+
// Accept either a named `RekorClient` constructor or a default export.
|
|
87
|
+
const ClientCtor =
|
|
88
|
+
(mod && (mod.RekorClient || (mod.default && mod.default.RekorClient))) || null;
|
|
89
|
+
if (typeof ClientCtor !== 'function') {
|
|
90
|
+
_cachedClient = null;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const baseURL =
|
|
94
|
+
typeof process.env.IJFW_REKOR_URL === 'string' && process.env.IJFW_REKOR_URL.length > 0
|
|
95
|
+
? process.env.IJFW_REKOR_URL
|
|
96
|
+
: 'https://rekor.sigstore.dev';
|
|
97
|
+
_cachedClient = new ClientCtor({ baseURL });
|
|
98
|
+
return _cachedClient;
|
|
99
|
+
} catch {
|
|
100
|
+
_cachedClient = null;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a Rekor client is available. Synchronous in spec but the underlying
|
|
107
|
+
* resolution is async (dynamic import); we expose an async probe that the
|
|
108
|
+
* caller awaits. Returns true when either the peer dep is installed or a
|
|
109
|
+
* test stub has been injected.
|
|
110
|
+
*
|
|
111
|
+
* @returns {Promise<boolean>}
|
|
112
|
+
*/
|
|
113
|
+
export async function hasRekorClient() {
|
|
114
|
+
const client = await resolveClient();
|
|
115
|
+
return client !== null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Submit a `{payload, signature, publicKey}` triple to the configured Rekor
|
|
120
|
+
* instance. The payload is the canonical signing bytes that the upstream
|
|
121
|
+
* Ed25519 signature was computed over (NOT the serialized registry).
|
|
122
|
+
*
|
|
123
|
+
* Returns the Rekor entry handle on success, or null on any failure
|
|
124
|
+
* (no client, network error, malformed response, unexpected exception).
|
|
125
|
+
* NEVER throws.
|
|
126
|
+
*
|
|
127
|
+
* Stderr advisory is emitted on failure so operators see when a sign-time
|
|
128
|
+
* Rekor anchor was attempted but skipped — important for audit trails when
|
|
129
|
+
* a registry ships without a `rekor` field.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} args
|
|
132
|
+
* @param {Buffer|string} args.payload canonical bytes of the signed body
|
|
133
|
+
* @param {string} args.signature Ed25519 signature string (e.g. "ed25519:<b64>")
|
|
134
|
+
* @param {string} args.publicKey PEM-encoded SPKI public key
|
|
135
|
+
* @returns {Promise<{ uuid: string, logIndex: number, integratedTime: number } | null>}
|
|
136
|
+
*/
|
|
137
|
+
export async function submitToRekor({ payload, signature, publicKey } = {}) {
|
|
138
|
+
const client = await resolveClient();
|
|
139
|
+
if (client === null) return null;
|
|
140
|
+
if (payload === undefined || signature === undefined || publicKey === undefined) {
|
|
141
|
+
advise('rekor: submit called without payload/signature/publicKey — skipping');
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const entry = await client.createEntry({ payload, signature, publicKey });
|
|
146
|
+
if (
|
|
147
|
+
!entry ||
|
|
148
|
+
typeof entry.uuid !== 'string' ||
|
|
149
|
+
typeof entry.logIndex !== 'number' ||
|
|
150
|
+
typeof entry.integratedTime !== 'number'
|
|
151
|
+
) {
|
|
152
|
+
advise('rekor: createEntry returned malformed response — skipping anchor');
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
uuid: entry.uuid,
|
|
157
|
+
logIndex: entry.logIndex,
|
|
158
|
+
integratedTime: entry.integratedTime,
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
advise(`rekor: submit failed — ${err.message || 'unknown error'}`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Verify that a Rekor entry's recorded payload hash matches the locally
|
|
168
|
+
* canonicalized payload. This is the detection mechanism for meta-key
|
|
169
|
+
* compromise: if an attacker swaps the meta-key but re-uses an old Rekor
|
|
170
|
+
* uuid, the payload hashes will not match and verification fails.
|
|
171
|
+
*
|
|
172
|
+
* Return values:
|
|
173
|
+
* - true — entry exists in Rekor AND its payload hash matches.
|
|
174
|
+
* - false — entry exists but payload hash MISMATCH (tamper detected, REJECT).
|
|
175
|
+
* - null — client unavailable OR entry lookup failed. Caller should fall
|
|
176
|
+
* back to the Ed25519 check alone (backcompat).
|
|
177
|
+
*
|
|
178
|
+
* NEVER throws.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} args
|
|
181
|
+
* @param {string} args.uuid Rekor entry uuid (from registry's embedded rekor field)
|
|
182
|
+
* @param {Buffer|string} args.payload canonical bytes the entry should attest to
|
|
183
|
+
* @returns {Promise<boolean|null>}
|
|
184
|
+
*/
|
|
185
|
+
export async function verifyRekorEntry({ uuid, payload } = {}) {
|
|
186
|
+
const client = await resolveClient();
|
|
187
|
+
if (client === null) return null;
|
|
188
|
+
if (typeof uuid !== 'string' || uuid.length === 0 || payload === undefined) {
|
|
189
|
+
advise('rekor: verify called without uuid/payload — skipping');
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
let entry;
|
|
193
|
+
try {
|
|
194
|
+
entry = await client.getEntry({ uuid });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
advise(`rekor: getEntry failed — ${err.message || 'unknown error'}`);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (!entry || typeof entry.payloadHash !== 'string' || entry.payloadHash.length === 0) {
|
|
200
|
+
advise('rekor: getEntry returned no payloadHash — cannot cross-check');
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const localHash = createHash('sha256')
|
|
204
|
+
.update(typeof payload === 'string' ? Buffer.from(payload, 'utf8') : payload)
|
|
205
|
+
.digest('hex');
|
|
206
|
+
return localHash === entry.payloadHash.toLowerCase();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Internal: stderr advisory. Always one-shot, never duplicated. Silenceable
|
|
211
|
+
// via IJFW_REKOR_QUIET=1 for noisy test environments.
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
function advise(message) {
|
|
215
|
+
if (process.env.IJFW_REKOR_QUIET === '1') return;
|
|
216
|
+
try {
|
|
217
|
+
process.stderr.write(`[ijfw] ${message}\n`);
|
|
218
|
+
} catch {
|
|
219
|
+
/* best-effort */
|
|
220
|
+
}
|
|
221
|
+
}
|