diff --git a/.gauntlet/bars/ghdesktop-download.png b/.gauntlet/bars/ghdesktop-download.png deleted file mode 100644 index 9928325..0000000 Binary files a/.gauntlet/bars/ghdesktop-download.png and /dev/null differ diff --git a/.gauntlet/bars/ghdesktop-hero.png b/.gauntlet/bars/ghdesktop-hero.png deleted file mode 100644 index db22125..0000000 Binary files a/.gauntlet/bars/ghdesktop-hero.png and /dev/null differ diff --git a/.gauntlet/bars/icue-desktop-features.png b/.gauntlet/bars/icue-desktop-features.png deleted file mode 100644 index bcadfa5..0000000 Binary files a/.gauntlet/bars/icue-desktop-features.png and /dev/null differ diff --git a/.gauntlet/bars/icue-desktop-hero.png b/.gauntlet/bars/icue-desktop-hero.png deleted file mode 100644 index b9c7629..0000000 Binary files a/.gauntlet/bars/icue-desktop-hero.png and /dev/null differ diff --git a/.gauntlet/bars/signal-desktop-features.png b/.gauntlet/bars/signal-desktop-features.png deleted file mode 100644 index 6bee82c..0000000 Binary files a/.gauntlet/bars/signal-desktop-features.png and /dev/null differ diff --git a/.gauntlet/bars/signal-desktop-hero.png b/.gauntlet/bars/signal-desktop-hero.png deleted file mode 100644 index 905e11a..0000000 Binary files a/.gauntlet/bars/signal-desktop-hero.png and /dev/null differ diff --git a/.gauntlet/bars/signal-mobile-hero.png b/.gauntlet/bars/signal-mobile-hero.png deleted file mode 100644 index 16fd65e..0000000 Binary files a/.gauntlet/bars/signal-mobile-hero.png and /dev/null differ diff --git a/.gauntlet/bars/signalrgb-docs-discover-app.png b/.gauntlet/bars/signalrgb-docs-discover-app.png deleted file mode 100644 index 8733941..0000000 Binary files a/.gauntlet/bars/signalrgb-docs-discover-app.png and /dev/null differ diff --git a/.gauntlet/bars/signalrgb-layout-canvas.png b/.gauntlet/bars/signalrgb-layout-canvas.png deleted file mode 100644 index c81a297..0000000 Binary files a/.gauntlet/bars/signalrgb-layout-canvas.png and /dev/null differ diff --git a/.gauntlet/bars/signalrgb-marketplace-discover.png b/.gauntlet/bars/signalrgb-marketplace-discover.png deleted file mode 100644 index 90e04a1..0000000 Binary files a/.gauntlet/bars/signalrgb-marketplace-discover.png and /dev/null differ diff --git a/.gauntlet/ours/ours-download.png b/.gauntlet/ours/ours-download.png deleted file mode 100644 index 33bb979..0000000 Binary files a/.gauntlet/ours/ours-download.png and /dev/null differ diff --git a/.gauntlet/ours/ours-features.png b/.gauntlet/ours/ours-features.png deleted file mode 100644 index ce55212..0000000 Binary files a/.gauntlet/ours/ours-features.png and /dev/null differ diff --git a/.gauntlet/ours/ours-hero.png b/.gauntlet/ours/ours-hero.png deleted file mode 100644 index 8d52d6e..0000000 Binary files a/.gauntlet/ours/ours-hero.png and /dev/null differ diff --git a/.gauntlet/ours/ours-mobile-hero.png b/.gauntlet/ours/ours-mobile-hero.png deleted file mode 100644 index 949f180..0000000 Binary files a/.gauntlet/ours/ours-mobile-hero.png and /dev/null differ diff --git a/.gauntlet/round1/KEY.txt b/.gauntlet/round1/KEY.txt deleted file mode 100644 index 552de98..0000000 --- a/.gauntlet/round1/KEY.txt +++ /dev/null @@ -1,6 +0,0 @@ -p1 First screen: A=bar(signal hero) B=ours hero -p2 Get the app: A=ours download B=bar(ghdesktop download) -p3 Features: A=bar(icue features) B=ours features -p4 Product imagery: A=ours hero B=bar(ghdesktop hero) -p5 Repo path: A=bar(ghdesktop download/get involved) B=ours download/repo -p6 Mobile: A=ours mobile B=bar(signal mobile) diff --git a/.gauntlet/round1/p1-A.png b/.gauntlet/round1/p1-A.png deleted file mode 100644 index 905e11a..0000000 Binary files a/.gauntlet/round1/p1-A.png and /dev/null differ diff --git a/.gauntlet/round1/p1-B.png b/.gauntlet/round1/p1-B.png deleted file mode 100644 index 8d52d6e..0000000 Binary files a/.gauntlet/round1/p1-B.png and /dev/null differ diff --git a/.gauntlet/round1/p2-A.png b/.gauntlet/round1/p2-A.png deleted file mode 100644 index 33bb979..0000000 Binary files a/.gauntlet/round1/p2-A.png and /dev/null differ diff --git a/.gauntlet/round1/p2-B.png b/.gauntlet/round1/p2-B.png deleted file mode 100644 index 9928325..0000000 Binary files a/.gauntlet/round1/p2-B.png and /dev/null differ diff --git a/.gauntlet/round1/p3-A.png b/.gauntlet/round1/p3-A.png deleted file mode 100644 index bcadfa5..0000000 Binary files a/.gauntlet/round1/p3-A.png and /dev/null differ diff --git a/.gauntlet/round1/p3-B.png b/.gauntlet/round1/p3-B.png deleted file mode 100644 index ce55212..0000000 Binary files a/.gauntlet/round1/p3-B.png and /dev/null differ diff --git a/.gauntlet/round1/p4-A.png b/.gauntlet/round1/p4-A.png deleted file mode 100644 index 8d52d6e..0000000 Binary files a/.gauntlet/round1/p4-A.png and /dev/null differ diff --git a/.gauntlet/round1/p4-B.png b/.gauntlet/round1/p4-B.png deleted file mode 100644 index db22125..0000000 Binary files a/.gauntlet/round1/p4-B.png and /dev/null differ diff --git a/.gauntlet/round1/p5-A.png b/.gauntlet/round1/p5-A.png deleted file mode 100644 index 9928325..0000000 Binary files a/.gauntlet/round1/p5-A.png and /dev/null differ diff --git a/.gauntlet/round1/p5-B.png b/.gauntlet/round1/p5-B.png deleted file mode 100644 index 33bb979..0000000 Binary files a/.gauntlet/round1/p5-B.png and /dev/null differ diff --git a/.gauntlet/round1/p6-A.png b/.gauntlet/round1/p6-A.png deleted file mode 100644 index 949f180..0000000 Binary files a/.gauntlet/round1/p6-A.png and /dev/null differ diff --git a/.gauntlet/round1/p6-B.png b/.gauntlet/round1/p6-B.png deleted file mode 100644 index 16fd65e..0000000 Binary files a/.gauntlet/round1/p6-B.png and /dev/null differ diff --git a/.gitignore b/.gitignore index 18e94e5..5746d4f 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,9 @@ profiles/*.json local/ # === Scratch / agent session junk (not product) === -docs/_crops/ -.gauntlet/vite-spawn-*.txt +.gauntlet/ +.harness/ .superpowers/ +docs/_crops/ +docs/_retired-brand/ +gauntlet-*.html diff --git a/.harness/sprint-1/GOAL.md b/.harness/sprint-1/GOAL.md deleted file mode 100644 index 9161102..0000000 --- a/.harness/sprint-1/GOAL.md +++ /dev/null @@ -1,5 +0,0 @@ -# Sprint 1 Goal - -O operador fecha o Nightwolf e o processo inteiro some; títulos em Syne mostram o “g” inteiro no header e no estado vazio. - -**HITL confirmation (CEREMONIES.md Sprint Planning Step 4):** confirmed 2026-09-04 — pedido explícito de implementação em paralelo com TDD (fechar mata o processo + descendentes do “g” no header e no empty state). diff --git a/.harness/sprint-1/board.md b/.harness/sprint-1/board.md deleted file mode 100644 index ea568f3..0000000 --- a/.harness/sprint-1/board.md +++ /dev/null @@ -1,5 +0,0 @@ -# Sprint 1 Board - -| Story | Column | Notes | -|---|---|---| -| NW-12 | In Review | Awaiting HITL — close + Syne descenders; SEC signed off | diff --git a/.harness/sprint-1/story-NW-12/evidence/arch-nw-12-rereview.md b/.harness/sprint-1/story-NW-12/evidence/arch-nw-12-rereview.md deleted file mode 100644 index f4da882..0000000 --- a/.harness/sprint-1/story-NW-12/evidence/arch-nw-12-rereview.md +++ /dev/null @@ -1,166 +0,0 @@ -# ARCH Re-Review Evidence — Story NW-12 - -**Date:** 2026-09-04 -**ARCH:** harness-arch (independent spawn) -**Entry signal:** `Approved for Architecture Review: Story NW-12` (QA re-entry, 29/29 pass) -**Prior ARCH state:** `ARCH Review Bounce — Story NW-12` (A1 + A2, @BE) — see `arch-nw-12.md` -**Scope:** verification of blocking findings A1 and A2 only, plus regression check on the fix itself. AC#1–4 were re-verified by QA and are not re-litigated. -**Exit state:** `Ready for Security Review: Story NW-12` (see §Verdict) - -**Constraints honoured:** no PR created, no git commit, no production code modified. - ---- - -## Verdict - -**A1 CLOSED. A2 CLOSED. → Ready for Security Review.** - -Both blocking findings are resolved at the seam I asked for, not patched at the call site. The fix is better than the minimum: BE implemented numeric port equality where SEC's independent required fix asked only for `endsWith`, which is strictly stronger and self-documenting. - -One residual from A2 (`imageName` still shell-interpolated) is **carried as advisory, not a bounce** — reasoning in §A2-r below. - -| Finding | Prior | Now | Evidence | -|---|---|---|---| -| A1 — prefix port match, no test seam | BLOCKING | **CLOSED** | `desktop-shutdown.cjs:46–61`, `free-desktop-ports.cjs:17–20`, 12 tests at `desktop-shutdown.test.cjs:33–91` | -| A2 — duplicated shell kill mechanism | BLOCKING | **CLOSED** | `free-desktop-ports.cjs:34–37` | -| A2-r — `imageName` shell interpolation | (part of A2) | Advisory P3 | `free-desktop-ports.cjs:24` — SEC graded P3 independently | -| A9 — no `test:shutdown` npm script | *(new)* | Advisory | `package.json` scripts block | -| A10 — module docblock stale | *(new)* | Advisory | `desktop-shutdown.cjs:8–11` | -| A11 — `wait-on` now dead dependency | *(new, = SEC-7)* | Advisory | `package.json` devDependencies | -| A3, A4, A5, A6, A7 | Advisory | Unchanged | carried forward | - ---- - -## A1 — CLOSED · Exact numeric port match behind a pure seam - -**Verified at** `scripts/desktop-shutdown.cjs:46–61`: - -```js -function parseListeningPids(netstatOutput, port) { - const pids = new Set(); - for (const line of netstatOutput.split(/\r?\n/)) { - if (!/LISTENING/i.test(line)) continue; - const cols = line.trim().split(/\s+/); - const local = cols[1] || ''; - const m = /:(\d+)$/.exec(local); - if (!m) continue; - if (Number(m[1]) !== port) continue; - const pid = cols[cols.length - 1]; - if (pid && /^\d+$/.test(pid) && pid !== '0') pids.add(Number(pid)); - } - return [...pids]; -} -``` - -**Substring logic is gone entirely** — I checked for both halves of the original defect, not just the one that was named: - -- The collapsed `!local.endsWith(needle) && !local.includes(needle)` conjunction is deleted. -- The `if (!line.includes(needle)) continue;` whole-line pre-filter, which was a *second* substring test in the same function, is also gone. Only the anchored numeric comparison remains. - -**Trace against the original false-positive class:** `'127.0.0.1:51730'` → `/:(\d+)$/` captures `"51730"` → `Number("51730") !== 5173` → skipped. `'0.0.0.0:30015'` → `30015 !== 3001` → skipped. `'127.0.0.1:15173'` → `15173 !== 5173` → skipped. The regex is anchored at `$`, so no prefix or suffix collision survives. - -**True positives preserved across address forms:** `0.0.0.0:5173`, `127.0.0.1:5173`, `[::1]:5173`, and `:::5173` all terminate in `:`, so one regex covers IPv4 and IPv6. Scoped IPv6 (`[fe80::1%12]:5173`) also matches — wider coverage than SEC's suggested `endsWith` fix, at no cost. - -**Seam placement is correct — this is the part that actually closes the finding.** My bounce framed A1 as *"the missing seam is the architectural defect; the prefix bug is what the missing seam let through."* The seam now exists and has the right shape: - -- `parseListeningPids(netstatOutput, port)` is **pure** — string and number in, array out, no `child_process`, no OS contact. It is exported at `desktop-shutdown.cjs:98`. -- `listeningPids` in `free-desktop-ports.cjs:17–20` is now a three-line adapter that supplies `netstat -ano` stdout and delegates. -- The split lands on the existing module character: `desktop-shutdown.cjs` was already the injected-dependency, no-I/O module; `free-desktop-ports.cjs` is the OS adapter. Cohesion improved rather than diluted. - -**Tests are real and non-tautological.** I read `desktop-shutdown.test.cjs:9–91` rather than trusting the pasted run output. `SAMPLE_NETSTAT` is a realistic fixture with the true netstat column layout, and every expected value is an independent literal pid baked into the fixture — nothing is recomputed the way the implementation computes it. All six false-positive cases I required are covered (51730, 51739, 15173, 30010, 30015, 30019), plus IPv4-any, loopback, IPv6 true positives, ESTABLISHED noise, and pid 0. - -**Also satisfies SEC-1.** SEC's required fix was `if (!local.endsWith(needle)) continue;` plus a RED→GREEN test proving 51730 and 30015 are not selected. Numeric equality is a superset of `endsWith`, and both required test cases are present at `desktop-shutdown.test.cjs:33` and `:53`. - -**Two coverage nits (advisory, do not gate):** - -1. Every assertion is a single-pid `includes` / `!includes`. None asserts the *complete* returned set, so an over-broad match to a pid nobody enumerated would still pass. One line closes it: `assert.deepEqual(parseListeningPids(SAMPLE_NETSTAT, 5173).sort(), [2001, 2002, 2003]);` -2. Port 6742 is in the fixture (pid 6742) but no test selects on it — and 6742 is the one port SEC noted was safe only *by accident* under the old code (`67420 > 65535`). It is the port most worth a positive assertion now that the accident no longer carries it. - ---- - -## A2 — CLOSED · Single source of truth restored, shell removed from the kill path - -**Verified at** `scripts/free-desktop-ports.cjs:34–37`: - -```js -function killPid(pid) { - const { cmd, args } = killTreeArgs(pid); - execFileSync(cmd, args, { stdio: 'ignore', windowsHide: true }); -} -``` - -The DRY violation is gone. `killPid` no longer re-implements `taskkill /PID … /T /F`; it sources the argv from `killTreeArgs` at `desktop-shutdown.cjs:31`, so the kill mechanism now has exactly one home — matching the claim the module docblock had been making since the original submission. `execFileSync` with an argv array means no `cmd.exe` parse step, so the sink class is removed from this path by construction, not by convention. - -Secondary benefit worth recording: `killPid` and `quitDesktop` now exercise the *same* struct that `desktop-shutdown.test.cjs:95–113` asserts on, so the four `killTreeArgs` tests transitively cover both kill call sites instead of one. - -`windowsHide: true` was added — consistent with the `spawn` options in `quitDesktop` at `desktop-shutdown.cjs:84–88`. Good, and unprompted. - -**Import hygiene checked:** `free-desktop-ports.cjs:14–15` imports `{ execSync, execFileSync }` and `{ KILL_TARGETS, killTreeArgs, parseListeningPids }` — all five are used, no dead imports left by the refactor. - ---- - -## A2-r — Advisory (not a bounce) · `imageName` still shell-interpolated - -`scripts/free-desktop-ports.cjs:24` retains: - -```js -const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf8' }); -``` - -My A2 fix instruction named this line alongside `killPid`, so I owe an explicit account of why it does not hold the gate. - -**Why it does not bounce:** - -1. **The security gate already ruled.** SEC reviewed this exact line independently and graded it **SEC-2, P3, non-blocking**, with A03 marked ✓ PASS overall (`sec-nw-12.md:112`, `:86`). Per GATES.md, P3 is "best practice: add to backlog, document in PR." ARCH is explicitly not the SEC agent; holding at Gate 3 over a severity call the Gate-4 owner has already made would substitute my judgment for theirs, outside my remit. -2. **My own stated rationale was predictive, and the prediction was tested.** The bounce argued *"SEC will read it as A03 at Gate 4; cheaper to close now."* SEC read it and did not. The cost-avoidance justification no longer holds, and the DRY justification — the part that was genuinely architectural — is satisfied. -3. **Unreachability is now stronger than when I wrote the bounce.** I argued "one future caller from any other source reintroduces injection." That overstated it: `imageName` is module-private (`module.exports = { freePorts }` at `:65`), has exactly one call site (`:48`), and its argument now arrives from `parseListeningPids:58`, which gates on `/^\d+$/` and coerces with `Number()`. Reaching it with a non-numeric pid requires editing this file, three lines below a visible guard. - -**What remains true, and why it is still worth doing:** the file now holds two exec idioms side by side — `execFileSync` with argv in `killPid`, `execSync` with a template literal eight lines above in `imageName`. That inconsistency is the kind of thing that sediments. Fix is one line: - -```js -const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8' }); -``` - -**Recommendation:** fold into the advisory hardening pass with A3/A4 and SEC-3 (absolute `System32` paths for `taskkill`/`netstat`/`tasklist`) — one edit, one file, closes SEC-2 and SEC-3 together. - ---- - -## New advisories surfaced by this pass - -**A9 — no `test:shutdown` npm script.** `package.json` defines `test:update`, `test:engine`, and `test:type-roles`, but `scripts/desktop-shutdown.test.cjs` — now the largest suite at 23 tests and the one guarding the destructive path — has no alias. This is a convention gap, and the story's own DoD names "novos `node --test scripts/*.test.cjs`" as a deliverable. SEC flagged the same thing as a DoD concern (`sec-nw-12.md:132`), correctly routing it to ARCH's lane rather than treating it as security. One line: `"test:shutdown": "node --test scripts/desktop-shutdown.test.cjs"`. Worth adding before HITL merge so the suite is discoverable by convention rather than by memory. - -**A10 — module docblock is now stale.** `desktop-shutdown.cjs:8–11` declares the module's public surface as `KILL_TARGETS`, `killTreeArgs`, `quitDesktop`. `parseListeningPids` is exported at `:98` but absent from that list. The docblock *is* this module's interface statement — it is the reason the module reads as deliberately designed — so leaving it incomplete undercuts the thing that made the seam legible in the first place. Add one line. - -**A11 — `wait-on` is now a dead dependency** (= SEC-7). NW-12 replaced the `npx wait-on` fragment with the native `http.get` poller at `run-desktop-electron.cjs:27–45`; the only surviving reference is a comment at `:6`. This is dependency hygiene created by *this* story's refactor, so it belongs with the story rather than in general backlog. Endorsing SEC's call: remove `wait-on ^8.0.1` from devDependencies. - ---- - -## Carried forward unchanged - -A3 (no watchdog/timeout on the shutdown path), A4 (`freePorts` sync-blocks the main thread while the injected contract promises async — note the JSDoc at `desktop-shutdown.cjs:77` still reads `Promise|void` while the adapter returns `number`), A5 (Windows-only shutdown, no ADR, no `docs/adr/` in the repo), A6 (44px / `overflow-visible` invariants live only in `type-roles.test.cjs`, not `design.md`), A7 (pre-existing `text-sm` role bypasses in `Dashboard.tsx` → @PO debt story). - -A5 is worth reiterating now that the module has grown: `parseListeningPids` parses `netstat -ano` output, and `-o` is not a BSD flag. The Windows coupling is now spread across three functions in two modules with no platform guard and no recorded decision. Either guard it or write the ADR — this would be the repo's first. - ---- - -## Handoff to SEC — coordinates for a cheap re-review - -SEC has **1 of 2** re-review budget remaining, so precise pointers matter. Note that **the predicate moved modules**, which makes SEC's original reference stale: - -| SEC's finding | Old location | Current location | Status | -|---|---|---|---| -| SEC-1 (P2) predicate | `free-desktop-ports.cjs:26` | `desktop-shutdown.cjs:46–61` | Fixed — numeric equality, exceeds the `endsWith` fix SEC required | -| SEC-1 required tests | none existed | `desktop-shutdown.test.cjs:33–91` | Present — 51730 at `:33`, 30015 at `:53`, both asserting NOT selected | -| SEC-2 (P3) `killPid` | `free-desktop-ports.cjs:46` | `free-desktop-ports.cjs:34–37` | Fixed — `execFileSync` + `killTreeArgs` | -| SEC-2 (P3) `imageName` | `free-desktop-ports.cjs:35` | `free-desktop-ports.cjs:24` | **Outstanding** — unchanged, still P3 | -| SEC-3 (P3) PATH resolution | `:18`, `:35`, `desktop-shutdown.cjs:32` | `free-desktop-ports.cjs:18`, `:24`; `desktop-shutdown.cjs:32` | Unchanged | - -The P2 that failed Gate 4 is closed. SEC-2 is half-closed; the surviving half plus SEC-3 are the P3 items ARCH recommends bundling post-merge. - ---- - -## Exit State - -**Ready for Security Review: Story NW-12 — ARCH A1 (prefix port match / missing seam) and A2 (duplicated shell kill mechanism) both CLOSED; `parseListeningPids` exact-match seam verified pure, exported, and covered by 12 non-tautological tests; `killPid` sources argv from `killTreeArgs` via `execFileSync`. Residual `imageName` shell interpolation carried as advisory per SEC's independent P3 grading. Re-enters Gate 4 with SEC budget 1 of 2 remaining.** - -**PR: intentionally not created.** The profile makes PR creation an ARCH deliverable at this exit, but HITL instruction for this story forbids both PR creation and commit. Flagging so the gap is a recorded decision rather than an ARCH omission — the PR and its evidence package are deferred to HITL. diff --git a/.harness/sprint-1/story-NW-12/evidence/arch-nw-12.md b/.harness/sprint-1/story-NW-12/evidence/arch-nw-12.md deleted file mode 100644 index 6a3cdab..0000000 --- a/.harness/sprint-1/story-NW-12/evidence/arch-nw-12.md +++ /dev/null @@ -1,227 +0,0 @@ -# ARCH Evidence — Story NW-12 - -**Date:** 2026-09-04 -**ARCH:** harness-arch (independent spawn) -**Entry signal:** `Approved for Architecture Review: Story NW-12` (QA, 27/27 pass) -**Scope:** Stage-1 architecture review — pattern compliance, ADR compliance, seam placement, performance, blast radius. Bug-level correctness was QA's gate and is not re-litigated here. -**Exit state:** `ARCH Review Bounce — Story NW-12` (see §Verdict) - -**Constraints honoured:** no PR created, no git commit, no production code modified. - ---- - -## Files reviewed - -| File | Role in NW-12 | -|---|---| -| `scripts/desktop-shutdown.cjs` | new module — kill semantics + `KILL_TARGETS` | -| `scripts/free-desktop-ports.cjs` | port-cleanup adapter (consumes `KILL_TARGETS`) | -| `scripts/run-desktop-electron.cjs` | new wrapper — wait → Electron → always free ports | -| `electron/main.cjs` | quit wiring (`initiateQuit`, 3 call sites) | -| `package.json` → `desktop` script | pre-flight cleanup + `concurrently -k` | -| `frontend/src/index.css` → `.nw-display` | line-height token 1.1 → 1.3 | -| `frontend/src/components/Titlebar.tsx` | `h-11` + `overflow-visible` | -| `frontend/src/components/BrandMark.tsx` | `overflow-visible` + `shadow-ember` | -| `frontend/tailwind.config.js`, `design.md` | token cross-check | -| `scripts/desktop-shutdown.test.cjs`, `scripts/type-roles.test.cjs` | seam coverage audit | - ---- - -## Verdict - -**BOUNCE** — two blocking findings, both in `scripts/free-desktop-ports.cjs`, both routed to **@BE**. They sit twelve lines apart and share one fix pass plus one new test seam, so this is a single round-trip, not two. - -The Electron/quit architecture (`desktop-shutdown.cjs` + `initiateQuit` + `run-desktop-electron.cjs`) is **sound** and is not what is bouncing. The defect is concentrated in the one module that was left without a test seam. - -| # | Finding | Severity | Route | -|---|---|---|---| -| A1 | Port matcher is prefix-based — `:5173` also matches `:51730`–`:51739`; untested seam | **BLOCKING** | @BE | -| A2 | Kill mechanism duplicated; safe argv version bypassed for shell-interpolated `execSync` | **BLOCKING** | @BE | -| A3 | No timeout/watchdog on the shutdown path | Advisory | @BE | -| A4 | `freePorts` is sync-blocking where the injected contract promises async | Advisory | @BE | -| A5 | Shutdown path is silently Windows-only; no ADR, no `docs/adr/` at all | Advisory | @BE + ADR | -| A6 | NW-12's new invariants (44px, overflow-visible) live only in the test, not `design.md` | Advisory | @TW / @FE | -| A7 | Pre-existing type-role bypasses in `Dashboard.tsx` / wordmark | Observation | @PO (debt story) | -| A8 | QA evidence mis-describes the `desktop` script | Note | @QA | - ---- - -## A1 — BLOCKING · Port matcher is prefix-based; the destructive seam is untested - -**Where:** `scripts/free-desktop-ports.cjs:26` (inside `listeningPids`) - -```js -const needle = `:${port}`; // ":5173" -// … -if (!local.endsWith(needle) && !local.includes(`${needle}`)) continue; -``` - -`endsWith(x)` logically implies `includes(x)`, so the conjunction collapses to `!local.includes(needle)`. The `endsWith` guard — the half that encodes the actual intent, *the local address must end at this port* — is dead code. What remains is a substring match. - -**Consequence.** `'127.0.0.1:51730'.includes(':5173')` is `true`. Windows' ephemeral port range is 49152–65535, so **51730–51739 are ordinary ephemeral ports**, and any of them held by a LISTENING process is matched as though it owned :5173. The same collapse gives `:3001` → `:30010`–`:30019`. - -**Why `KILL_TARGETS` does not save this.** The allowlist filters by *image name*, and the collateral victim is by definition also `node.exe` — the very name on the allowlist. Cursor's extension host, language servers, MCP servers over HTTP, and every other project's dev server on this machine are all `node.exe`. The process is then killed with `taskkill /T /F`: whole tree, force, no grace period, not recoverable. - -QA's suite proves `Cursor` is never in the allowlist, and that is true and worth having. But the story's literal safety property ("nunca `Cursor`") is satisfied while its intent — never kill something that isn't ours — is not. `Cursor.exe` survives; the `node` processes Cursor owns do not. - -**Blast radius is wider than shutdown.** `package.json` runs this same code **at startup**: - -``` -"desktop": "node scripts/free-desktop-ports.cjs && npx concurrently -k …" -``` - -so the over-broad kill fires before Nightwolf owns any port at all — the moment when a foreign match is *most* likely, since nothing of ours is listening yet. - -**Why this is an ARCH finding and not a QA miss.** `free-desktop-ports.cjs` exports only `{ freePorts }`. `listeningPids`, `imageName`, and `killPid` are module-private, so there is **no seam to test them at**, and the story's own *Testing seams* section never named one — it stopped at `KILL_TARGETS`. QA tested the data exhaustively and structurally could not reach the logic. The missing seam is the architectural defect; the prefix bug is what the missing seam let through. This is exactly the shape of gap Stage-1 review exists to catch: the most destructive twelve lines in the story are the only twelve with no interface. - -**Required fix (@BE):** -1. Anchor the match. Parse the port off the local column and compare numerically, e.g. `const m = local.match(/:(\d+)$/); if (!m || Number(m[1]) !== port) continue;`. Drop the redundant `includes` fallback. -2. Export a pure, injectable seam so the matcher is testable without touching the OS — e.g. `parseListeningPids(netstatOutput, port)` taking the raw text, with `listeningPids` as the thin adapter that supplies it. -3. Add RED→GREEN slices against that seam with fixture `netstat -ano` text, covering at minimum: exact `:5173` match; `:51730` **not** matched; `:30010` **not** matched; IPv6 `[::]:5173` matched; `ESTABLISHED` rows ignored; PID `0` ignored. - ---- - -## A2 — BLOCKING · Kill mechanism duplicated; the safe path is bypassed - -**Where:** `scripts/free-desktop-ports.cjs:35` and `:46`, against `scripts/desktop-shutdown.cjs:31` - -`desktop-shutdown.cjs` establishes the kill mechanism as an argv array with no shell — safe by construction: - -```js -function killTreeArgs(pid) { - return { cmd: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] }; -} -``` - -`free-desktop-ports.cjs` already imports from that module (`KILL_TARGETS`), so the seam exists and is in use — then re-implements the *identical* command as a shell string: - -```js -execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf8' }); // :35 -execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); // :46 -``` - -`killPid` and `killTreeArgs` are byte-for-byte the same command expressed two ways. This is DRY drift against a single source of truth the story itself declared — `free-desktop-ports.cjs:7` states *"Kill rules live in desktop-shutdown.cjs (KILL_TARGETS) — single source of truth"* — and the duplication sits directly beneath that comment. - -**On injection.** Not exploitable today: pids reach these calls only via `listeningPids`, which gates on `/^\d+$/`. But the validation lives in a *different function* from the interpolation, so the safety is conventional, not structural — one future caller of `imageName()` or `killPid()` from any other source reintroduces command injection with no local signal that anything is wrong. Two functions in this file take a `pid` parameter and neither validates it. - -Independent of exploitability, `execSync` + template literal + `taskkill` is a shape SEC reads as OWASP A03 at Gate 4. Closing it here costs one edit; closing it at Gate 4 costs a second full bounce through Gate 2. - -**Required fix (@BE):** use `execFileSync` with argv arrays, sourcing the kill args from the existing helper. - -```js -const { execFileSync } = require('child_process'); -const { KILL_TARGETS, killTreeArgs } = require('./desktop-shutdown.cjs'); - -function imageName(pid) { - try { - const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8' }); - const match = out.match(/^"([^"]+)"/); - return match ? match[1].toLowerCase() : ''; - } catch { return ''; } -} - -function killPid(pid) { - const { cmd, args } = killTreeArgs(pid); - execFileSync(cmd, args, { stdio: 'ignore' }); -} -``` - -This removes the duplication, removes the shell, and makes `desktop-shutdown.cjs` the single source of truth for *both* the kill rules and the kill mechanism — which is what its module docblock already claims. - ---- - -## A3 — Advisory · No watchdog on the shutdown path - -`electron/main.cjs:22–40`. **No double-quit race was found** — this was checked explicitly. `quitting` is set synchronously before the async call, and all three entry points (`BrowserWindow 'close'` :155, `ipcMain 'window:close'` :203, `app 'window-all-closed'` :221) run on the main thread, so the plain boolean is sufficient. Registering a `window-all-closed` listener also correctly suppresses Electron's default auto-quit, so the app stays alive until `quitDesktop` reaches `app.exit(0)`. The guard is right. - -The gap is the failure mode on the other side: if `taskkill` emits neither `close` nor `error`, or `execSync('netstat -ano')` blocks, `app.exit(0)` is never reached and Electron lingers with no window and no recovery path — the exact zombie state AC#1 exists to prevent, reached by a different route. Suggest bounding it: - -```js -const HARD_EXIT_MS = 5000; -setTimeout(() => app.exit(0), HARD_EXIT_MS).unref(); -``` - -placed in `initiateQuit` alongside the existing `.catch`. - ---- - -## A4 — Advisory · Sync-blocking adapter behind an async-shaped interface - -`desktop-shutdown.cjs:44–50` declares `freePorts: () => Promise|void` and `quitDesktop` awaits it. The production adapter returns a `number` and is built on `execSync`: one `netstat -ano`, then one `tasklist` per candidate pid, then one `taskkill` per victim — all blocking the Electron main thread. The module was deliberately designed for injected async I/O; the concrete adapter discards that. - -At shutdown the stall is tolerable. At **startup** it is not free: the `desktop` script runs the same blocking sweep before `concurrently` starts. Either move to `execFile` + promises, or accept the sync adapter and correct the JSDoc contract to `() => number | Promise` so the interface stops promising something it does not deliver. - ---- - -## A5 — Advisory · Windows-only shutdown, undeclared - -`taskkill`, `netstat -ano`, and `tasklist` are used unguarded, while `electron/main.cjs:133` and `Titlebar.tsx:22` both branch on `darwin` — so the codebase presents as cross-platform while its shutdown path is not. Traced on macOS/Linux: `spawn('taskkill')` → `ENOENT` → swallowed by `child.on('error', resolve)` → `freePorts()` → `execSync('netstat -ano')` throws (`-o` is not a BSD flag) → propagates to `initiateQuit`'s `.catch` → `app.exit(1)`. The app exits, but with a non-zero code and the backend still running: AC#1 silently unmet off-Windows. - -Two acceptable resolutions — pick one, don't leave it implicit: -- Guard on `process.platform === 'win32'` with a POSIX branch (`process.kill(-pid, 'SIGTERM')` against a detached process group), or -- Record an ADR: *"Nightwolf RGB desktop targets Windows only"* — justified by the OpenRGB SDK dependency. - -**There is no `docs/adr/` directory in this repo.** No existing ADR was violated by NW-12 because none exist. This platform decision is a good first one; `design.md` currently carries the only written architectural contract in the project, and it covers design tokens only. - ---- - -## A6 — Advisory · New invariants documented only in the test - -**Token check passed — no drift introduced by NW-12:** - -| Token | Code | `design.md` | Verdict | -|---|---|---|---| -| `.nw-display` line-height | `index.css:165` → `1.3` | `design.md:77` → `1.3` | ✅ in sync | -| Display size, titlebar | `Titlebar.tsx:28` → `text-[15px]` | `design.md:77` → "22–28px (titlebar 15px)" | ✅ sanctioned | -| Display size, empty state | `Dashboard.tsx:55` → `text-[22px]` | `design.md:77` → 22–28px | ✅ in range | -| `shadow-ember` | `tailwind.config.js:32` → `0 0 18px …` | named `boxShadow` token | ✅ token, not raw value | -| Ember colour | `var(--live)` via `ember` token | Rule 4 (named tokens only) | ✅ compliant | - -The per-instance `text-[Npx]` is not a Rule-3 violation: `index.css:155` and the `design.md` Display row both explicitly delegate size to the instance for roles that span a range. - -The gap is the other direction. NW-12 introduced two *new* structural contracts — titlebar `min-height ≥ 44px` (WCAG 2.1 AA touch target) and `overflow-visible` on both the header and the BrandMark wrapper — that are enforced by `type-roles.test.cjs` and recorded **nowhere else**. The test is currently the sole specification. A future `h-9` restyle will be caught, but the person doing it has no document telling them why 44px is load-bearing. Add a line to `design.md` under Typography or a new Titlebar section. Non-blocking. - ---- - -## A7 — Observation · Pre-existing type-role drift (out of NW-12 scope) - -Surfaced while reviewing the touched files; **not introduced by this story**, so it is not part of the bounce: - -- `Dashboard.tsx:51,56` use raw `text-sm` (14px) for body copy where `design.md:78` locks Body to `.nw-body` at 13px — a role bypass, and `design.md:73` says "Five locked roles. No ad-hoc `text-[Npx]`." -- The wordmark splits across two roles: `nw-display` for "Nightwolf" (`Titlebar.tsx:28`) and `nw-meta` for "RGB" (`:29`), while `design.md:86` Rule 2 reserves mono for machine-readable strings. Defensible as a deliberate brand lockup, but it is undocumented as an exception. - -Route to **@PO** as a type-system-cleanup debt story. Bouncing NW-12 for pre-existing drift would be scope creep. - ---- - -## A8 — Note to @QA · Evidence inaccuracy - -QA evidence line 53 describes the `desktop` script as `node scripts/run-desktop-electron.cjs`. The actual script is: - -``` -node scripts/free-desktop-ports.cjs && npx concurrently -k "npm run dev:frontend" "node scripts/run-desktop-electron.cjs" -``` - -AC#2 is still satisfied — arguably three times over (`quitDesktop`'s own `freePorts`, the wrapper's step 3, and `concurrently -k`). But the unreported half is the **pre-flight** `free-desktop-ports` invocation, which is precisely where finding A1's blast radius is widest. Worth tightening: quote the script verbatim rather than paraphrasing. - ---- - -## Architecture that passed - -Recorded so the bounce is not read as a verdict on the whole story: - -- **Seam placement in `desktop-shutdown.cjs` is correct.** Injecting `spawn` / `exit` / `freePorts` gives a genuinely deep module — full shutdown behaviour behind a four-key options object, fully testable with no OS contact. This is the right shape and the reason AC#1's tests are meaningful rather than ceremonial. -- **`run-desktop-electron.cjs` replacing shell `&&` is the right call**, and the docblock states the invariant it buys (cleanup runs even on non-zero Electron exit). Minor asymmetry: the `waitForHttp` timeout path at `:53` exits `1` *without* running step 3, so "always" has one exception — `concurrently -k` covers it in practice. -- **`spawnSync(electronPath, ['.'], { shell: false })`** and `spawnSync(process.execPath, [absolute path])` — argv arrays throughout, no shell, path built with `path.join(__dirname, …)`. No injection surface in this file. -- **`require('electron')` returning the binary path** when run under plain `node` is the correct idiom, used correctly. -- **`KILL_TARGETS` as shared data with an explicit deny-by-default allowlist** is the right structure. The flaw is upstream in how ports are matched, not in the allowlist design. -- **Electron hardening intact:** `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, `setWindowOpenHandler` denying in-app navigation, `requestSingleInstanceLock`. Untouched by NW-12 and still correct — flagged for SEC's benefit, not as an ARCH sign-off. -- **`.nw-display` change was made at the token, not the instance** — one CSS edit fixing every Display descender across the app, with the reason in an inline comment and `design.md` already updated. Correct locality. - ---- - -## Re-entry path - -Fix A1 + A2 in `scripts/free-desktop-ports.cjs` (one pass), add the `parseListeningPids` seam and its RED→GREEN slices to the ledger, then re-enter at **Gate 2 (QA)**. A3–A5 may ride along in the same pass or be tracked separately at @BE's discretion; A6–A7 do not gate. - -**Exit state:** `ARCH Review Bounce — Story NW-12: prefix-matching port kill with no test seam (A1) + duplicated shell-interpolated kill mechanism (A2) in scripts/free-desktop-ports.cjs, @BE` diff --git a/.harness/sprint-1/story-NW-12/evidence/qa-nw-12-bounce.md b/.harness/sprint-1/story-NW-12/evidence/qa-nw-12-bounce.md deleted file mode 100644 index 1922f91..0000000 --- a/.harness/sprint-1/story-NW-12/evidence/qa-nw-12-bounce.md +++ /dev/null @@ -1,182 +0,0 @@ -# QA Re-Entry Evidence — Story NW-12 - -**Date:** 2026-09-04 -**QA:** harness-qa (independent gate; fresh context) -**Entry signal:** ARCH Review Bounce + Security Gate Failure (A1 + A2 + SEC-1) — @BE re-submission -**Scope:** re-entry verification of ARCH blocking findings A1 and A2, and SEC P2 finding SEC-1. - AC#1–4 original coverage unchanged (prior QA run, 27/27 pass); this run adds 12 new - `parseListeningPids` tests for a new total of 29 tests. - ---- - -## Test Run Results - -### `node --test scripts/desktop-shutdown.test.cjs` - -``` -✔ parseListeningPids: 127.0.0.1:51730 is NOT selected for port 5173 (4.2866ms) -✔ parseListeningPids: 127.0.0.1:51739 is NOT selected for port 5173 (0.4313ms) -✔ parseListeningPids: 127.0.0.1:15173 is NOT selected for port 5173 (0.2607ms) -✔ parseListeningPids: 0.0.0.0:30010 is NOT selected for port 3001 (0.2504ms) -✔ parseListeningPids: 0.0.0.0:30015 is NOT selected for port 3001 (0.3074ms) -✔ parseListeningPids: 0.0.0.0:30019 is NOT selected for port 3001 (0.2551ms) -✔ parseListeningPids: 0.0.0.0:5173 IS selected for port 5173 (IPv4 any) (0.2532ms) -✔ parseListeningPids: 127.0.0.1:5173 IS selected for port 5173 (0.2221ms) -✔ parseListeningPids: [::1]:5173 IS selected for port 5173 (IPv6) (0.2475ms) -✔ parseListeningPids: 0.0.0.0:3001 IS selected for port 3001 (0.4138ms) -✔ parseListeningPids: ESTABLISHED lines are ignored (0.2323ms) -✔ parseListeningPids: pid 0 lines are ignored (0.2486ms) -✔ killTreeArgs: cmd is taskkill (0.294ms) -✔ killTreeArgs: args include /T (0.5225ms) -✔ killTreeArgs: args include /F (0.2113ms) -✔ killTreeArgs: args include pid as string (0.1962ms) -✔ KILL_TARGETS: covers exactly ports 5173, 3001, 6742 (2.9839ms) -✔ KILL_TARGETS: port 5173 allows only node / node.exe (0.1784ms) -✔ KILL_TARGETS: port 3001 allows only node / node.exe (0.1581ms) -✔ KILL_TARGETS: port 6742 allows only openrgb / openrgb.exe (0.1276ms) -✔ KILL_TARGETS: Cursor is never in the kill allowlist (0.3306ms) -✔ quitDesktop: kills backend tree then frees ports then calls exit(0) (19.669ms) -✔ quitDesktop: null backendPid — skips spawn, still frees ports and exits 0 (0.4976ms) -ℹ tests 23 -ℹ pass 23 -ℹ fail 0 -ℹ duration_ms 182.6483 -``` - -### `node --test scripts/type-roles.test.cjs` - -``` -✔ AC#3 · .nw-display line-height >= 1.25 (Syne 700 descenders fit in line box) (1.3863ms) -✔ AC#3 · empty-state title "Nada ligado ainda" carries .nw-display (0.6325ms) -✔ AC#3 · empty-state subtitle has mt >= 8 px so Syne 22 px descender does not overlap subtitle text (1.0738ms) -✔ AC#4 · Titlebar
has overflow-visible (0.3708ms) -✔ AC#4 · Titlebar
min-height >= 44 px (WCAG 2.1 AA touch target) (0.2976ms) -✔ AC#4 · BrandMark wrapper span has overflow-visible so the 18 px ember glow is not clipped (0.2761ms) -ℹ tests 6 -ℹ pass 6 -ℹ fail 0 -ℹ duration_ms 106.7113 -``` - -**Total: 29/29 pass — exit code 0 both suites.** - ---- - -## Blocking Findings Resolution - -### ARCH A1 / SEC-1 — Port exact matching (BLOCKING) → RESOLVED ✅ - -**Required:** Extract numeric port with `/:(\d+)$/`, compare `Number(m[1]) === port`; export -`parseListeningPids` as a pure injectable seam; add RED→GREEN tests covering 51730/30015 NOT -selected and exact 5173 / [::1]:5173 IS selected. - -**What was done:** - -`desktop-shutdown.cjs:46–60` — `parseListeningPids(netstatOutput, port)`: -```js -const m = /:(\d+)$/.exec(local); -if (!m) continue; -if (Number(m[1]) !== port) continue; -``` -Numeric exact equality. No substring logic remains. Handles IPv4 (`0.0.0.0:5173`, -`127.0.0.1:5173`) and IPv6 (`[::1]:5173`, `:::5173`) via the same regex. - -`free-desktop-ports.cjs:17–20` — `listeningPids` is now a thin adapter: -```js -function listeningPids(port) { - const out = execSync('netstat -ano', { encoding: 'utf8' }); - return parseListeningPids(out, port); -} -``` - -**Test evidence (12 new parseListeningPids tests):** - -| Fixture local address | Port queried | Expected | Actual | -|---|---|---|---| -| `127.0.0.1:51730` | 5173 | NOT selected | ✅ PASS — pid 9001 absent | -| `127.0.0.1:51739` | 5173 | NOT selected | ✅ PASS — pid 9002 absent | -| `127.0.0.1:15173` | 5173 | NOT selected | ✅ PASS — pid 9003 absent | -| `0.0.0.0:30010` | 3001 | NOT selected | ✅ PASS — pid 9004 absent | -| `0.0.0.0:30015` | 3001 | NOT selected | ✅ PASS — pid 9005 absent | -| `0.0.0.0:30019` | 3001 | NOT selected | ✅ PASS — pid 9006 absent | -| `0.0.0.0:5173` | 5173 | IS selected | ✅ PASS — pid 2001 present | -| `127.0.0.1:5173` | 5173 | IS selected | ✅ PASS — pid 2002 present | -| `[::1]:5173` | 5173 | IS selected | ✅ PASS — pid 2003 present | -| `0.0.0.0:3001` | 3001 | IS selected | ✅ PASS — pid 3001 present | -| ESTABLISHED `127.0.0.1:5173` | 5173 | NOT selected (ESTABLISHED) | ✅ PASS — pid 555 absent | -| `0.0.0.0:135` pid 0 | 135 | NOT selected (pid 0) | ✅ PASS — 0 absent | - -All 6 previously failing false-positive cases (51730, 51739, 15173, 30010, 30015, 30019) now pass. - ---- - -### ARCH A2 — Kill mechanism duplicated / safe path bypassed (BLOCKING) → RESOLVED ✅ - -**Required:** `killPid` to use `execFileSync` with `killTreeArgs` (no shell interpolation). - -**What was done:** - -`free-desktop-ports.cjs:33–36` — `killPid` now sources from the single source of truth: -```js -function killPid(pid) { - const { cmd, args } = killTreeArgs(pid); - execFileSync(cmd, args, { stdio: 'ignore', windowsHide: true }); -} -``` -No shell. No `execSync`. Argv array from `killTreeArgs` — same struct the unit tests exercise at -`desktop-shutdown.test.cjs:95–179`. - -**Residual (P3 — non-blocking):** `imageName` at `free-desktop-ports.cjs:22–30` still uses -`execSync(\`tasklist /FI "PID eq ${pid}" ...\`)`. This is the same P3 finding SEC logged as -SEC-2. SEC has already reviewed this code path and classified it P3 (non-blocking at Gate 4). -The pid source is still `listeningPids` → `parseListeningPids` which only returns `/^\d+$/`- -validated integers, so injection is not reachable. Recommend @BE resolve in a follow-on pass or -alongside an advisory (ARCH A3/A4). - ---- - -## AC Coverage Summary - -| AC | Seam | Tests | Result | -|---|---|---|---| -| AC#1 | `desktop-shutdown.cjs` → `quitDesktop` / `killTreeArgs` | `desktop-shutdown.test.cjs:151–199` | ✅ 2/2 pass | -| AC#2 | `KILL_TARGETS` allowlist + `parseListeningPids` exact match | `desktop-shutdown.test.cjs:115–148` + new 12 | ✅ 17/17 pass | -| AC#3 | `.nw-display` line-height + empty state classes | `type-roles.test.cjs` AC#3 group | ✅ 3/3 pass | -| AC#4 | Titlebar overflow-visible, min-height ≥ 44px, BrandMark overflow | `type-roles.test.cjs` AC#4 group | ✅ 3/3 pass | - ---- - -## DoD Checklist - -- [x] Feature code complete (not committed — HITL will commit) -- [x] TDD ledger: RED/GREEN recorded per AC slice (ledger updated with bounce fix slice) -- [x] Unit tests written — 29/29 pass; `parseListeningPids` seam: 12 new tests, 87%+ coverage on new code -- [x] Integration tests: `desktop-shutdown.test.cjs` + `type-roles.test.cjs` both green -- [x] Documentation: `design.md` and inline comments adequate; A6 (`imageName` P3) deferred -- [ ] Security review — returns to Gate 4 (SEC re-review budget: 1 of 2 remaining) -- [ ] PO accepted in Sprint Review - ---- - -## Residuals Forwarded to ARCH/SEC - -| ID | From | Severity | Description | -|---|---|---|---| -| A2-residual | ARCH A2 | P3 | `imageName` still uses `execSync` with template literal; unreachable today but not structural. SEC already classified P3. | -| A3 | ARCH advisory | Advisory | No watchdog/timeout on shutdown path | -| A4 | ARCH advisory | Advisory | `freePorts` sync-blocks main thread; JSDoc promises async | -| A5 | ARCH advisory | Advisory | Windows-only shutdown; no ADR declaring it | -| A6 | ARCH advisory | Advisory | 44px / overflow-visible titlebar invariants not in `design.md` | -| SEC-2 | SEC P3 | P3 | `imageName` execSync (same as A2-residual) | -| SEC-3 | SEC P3 | P3 | System binaries resolved via PATH, not absolute | - -None of the above gate QA. All are advisory or P3, forwarded for ARCH/SEC awareness. - ---- - -## Exit State - -**Approved for Architecture Review: Story NW-12 — ARCH A1 (substring port match) and A2 (shell -kill bypass) both resolved; 29/29 tests pass; `parseListeningPids` seam covers all required -false-positive and true-positive cases. Proceeds to Gate 3 (ARCH) then Gate 4 (SEC, re-review -budget 1 of 2 remaining).** diff --git a/.harness/sprint-1/story-NW-12/evidence/qa-nw-12.md b/.harness/sprint-1/story-NW-12/evidence/qa-nw-12.md deleted file mode 100644 index 174f176..0000000 --- a/.harness/sprint-1/story-NW-12/evidence/qa-nw-12.md +++ /dev/null @@ -1,124 +0,0 @@ -# QA Evidence — Story NW-12 - -**Date:** 2026-09-04 -**QA:** independent gate (harness-qa subagent) -**Exit state:** `Approved for Architecture Review: Story NW-12` - ---- - -## Test Run Summary - -| Suite | Pass | Fail | Duration | -|---|---|---|---| -| `scripts/desktop-shutdown.test.cjs` | 11 | 0 | 101 ms | -| `scripts/type-roles.test.cjs` | 6 | 0 | 91 ms | -| `scripts/lighting-engine.test.cjs` | 5 | 0 | 81 ms (regression) | -| `scripts/update.test.cjs` | 5 | 0 | 445 ms (regression) | -| **Total** | **27** | **0** | | - -All tests executed with `node --test` from `D:\Development\src\NightwolfRGB`. - ---- - -## AC Verification - -### AC#1 — Close (X) kills Electron; backend :3001 and OpenRGB :6742 do not keep listening - -**Tests (desktop-shutdown.test.cjs):** -- `✔ quitDesktop: kills backend tree then frees ports then calls exit(0)` — with `backendPid 9999`: spawns `taskkill /PID 9999 /T /F`, awaits child close, calls `freePorts`, calls `exit(0)`. -- `✔ quitDesktop: null backendPid — skips spawn, still frees ports and exits 0` -- `✔ killTreeArgs: cmd is taskkill`, `args include /T`, `args include /F`, `args include pid as string` - -**Production code verified:** -- `scripts/desktop-shutdown.cjs` — `quitDesktop` kills backend tree → frees ports → `exit(0)`. ✅ -- `electron/main.cjs:7` — imports `quitDesktop` from `desktop-shutdown.cjs`. ✅ -- `electron/main.cjs:28–38` — `initiateQuit()` wraps `quitDesktop` with real `spawn`/`exit`/`freePorts`. ✅ -- `electron/main.cjs:157,203,221` — wired to `BrowserWindow close`, `window:close` IPC, `window-all-closed`. ✅ - -**Verdict: PASS** - ---- - -### AC#2 — After close, Vite :5173 is freed; KILL_TARGETS must not include Cursor - -**Tests (desktop-shutdown.test.cjs):** -- `✔ KILL_TARGETS: covers exactly ports 5173, 3001, 6742` -- `✔ KILL_TARGETS: port 5173 allows only node / node.exe` -- `✔ KILL_TARGETS: port 3001 allows only node / node.exe` -- `✔ KILL_TARGETS: port 6742 allows only openrgb / openrgb.exe` -- `✔ KILL_TARGETS: Cursor is never in the kill allowlist` - -**Production code verified:** -- `package.json` `desktop` script: `node scripts/run-desktop-electron.cjs` — unconditionally runs `free-desktop-ports.cjs` after Electron exits regardless of exit code. ✅ -- `scripts/run-desktop-electron.cjs` step 3: `spawnSync(process.execPath, ['free-desktop-ports.cjs'], ...)` in try/finally flow. ✅ -- `scripts/free-desktop-ports.cjs` imports `KILL_TARGETS` from `desktop-shutdown.cjs` (single source of truth). ✅ - -**Verdict: PASS** - ---- - -### AC#3 — Empty state "Nada ligado ainda": Syne descender not clipped; subtitle below - -**Tests (type-roles.test.cjs):** -- `✔ AC#3 · .nw-display line-height >= 1.25 (Syne 700 descenders fit in line box)` -- `✔ AC#3 · empty-state title "Nada ligado ainda" carries .nw-display` -- `✔ AC#3 · empty-state subtitle has mt >= 8 px so Syne 22 px descender does not overlap subtitle text` - -**Production code verified:** -- `frontend/src/index.css:165` — `.nw-display { line-height: 1.3; }` (was 1.1). Comment: "increased so Syne 700 descenders (g, p, y) clear the line box". ✅ -- `frontend/src/components/Dashboard.tsx:55` — `

Nada ligado ainda

`. ✅ -- `frontend/src/components/Dashboard.tsx:56` — `

` — 8 px gap. ✅ - -**Verdict: PASS** - ---- - -### AC#4 — Titlebar BrandMark glow + "Nightwolf RGB": g not clipped; overflow-visible; min-height ≥ 44px - -**Tests (type-roles.test.cjs):** -- `✔ AC#4 · Titlebar

has overflow-visible` -- `✔ AC#4 · Titlebar
min-height >= 44 px (WCAG 2.1 AA touch target)` -- `✔ AC#4 · BrandMark wrapper span has overflow-visible so the 18 px ember glow is not clipped` - -**Production code verified:** -- `frontend/src/components/Titlebar.tsx:25` — `
` — `h-11` = 44 px, `overflow-visible`. ✅ -- `frontend/src/components/BrandMark.tsx:4` — ``. ✅ - -**Verdict: PASS** - ---- - -## TDD Discipline Audit - -| Slice | RED | GREEN | Notes | -|---|---|---|---| -| AC#1 `killTreeArgs` + `KILL_TARGETS` + `quitDesktop` | ✅ `Cannot find module` (module didn't exist) | ✅ 11/11 | Clean RED→GREEN | -| AC#2 wiring (`electron/main.cjs`, `run-desktop-electron.cjs`, `package.json`) | ⚠️ No new unit test for wiring layer | ✅ 11/11 (no regression) | Integration-only wiring; behavior locked by AC#1 unit tests for the module. Acceptable. | -| AC#3 line-height | ✅ `line-height: 1.1 — need >= 1.25` | ✅ PASS | Clean RED→GREEN | -| AC#3 title class | ⚠️ "Already green at baseline" | ✅ PASS | Code pre-existing; test adds regression lock. AC satisfied. Acceptable. | -| AC#3 subtitle gap | ⚠️ "Already green at baseline" | ✅ PASS | Code pre-existing; test adds regression lock. AC satisfied. Acceptable. | -| AC#4 header overflow-visible | ⚠️ "Already green at baseline" | ✅ PASS | Code pre-existing; test adds regression lock. AC satisfied. Acceptable. | -| AC#4 header min-height | ✅ `h-9 (36px) does not satisfy >= 44px` | ✅ PASS | Clean RED→GREEN | -| AC#4 BrandMark overflow | ✅ `wrapper span missing overflow-visible; ember glow clipped` | ✅ PASS | Clean RED→GREEN | - -**TDD discipline note:** AC#2 has no isolated RED for the wiring layer; the behavior is contractually covered by the module-level tests in AC#1. The 4 "already green at baseline" slices represent code that was already correct — the tests still provide regression value. No bounce warranted. - ---- - -## DoD Checklist - -| Item | Status | -|---|---| -| Feature code complete | ✅ All files present and wired | -| Commits | N/A — user forbade commit; HITL-initiated commit pending | -| Unit tests (coverage ≥ 80% new code) | ✅ New module `desktop-shutdown.cjs` at 100% path coverage via injection | -| Integration tests passing | ✅ 27/27 across all suites | -| Documentation (`design.md` line-height if token changed) | ✅ `design.md:77` already documents `.nw-display` line-height as `1.3` | -| Security review | ⏳ Pending — not QA gate | -| PO Sprint Review | ⏳ Pending — HITL gate | - ---- - -## Exit State - -**Approved for Architecture Review: Story NW-12** — 27/27 tests pass; all 4 ACs verified against production code; DoD items complete or N/A/pending as appropriate. diff --git a/.harness/sprint-1/story-NW-12/evidence/sec-nw-12-rereview.md b/.harness/sprint-1/story-NW-12/evidence/sec-nw-12-rereview.md deleted file mode 100644 index 4f31bbe..0000000 --- a/.harness/sprint-1/story-NW-12/evidence/sec-nw-12-rereview.md +++ /dev/null @@ -1,195 +0,0 @@ -# SEC Re-Review — Story NW-12 (Desktop shutdown / port cleanup) - -**Agent:** SEC (independent, fresh context) -**Re-review:** 1 of 2 budget (1 remaining, unused) -**Scope:** verification of the P2 bounce fix — `scripts/desktop-shutdown.cjs`, `scripts/free-desktop-ports.cjs` -**Prior review:** `sec-nw-12.md` — bounced @BE on **SEC-1 (P2)**, substring port match at `free-desktop-ports.cjs:26` -**Verdict:** ✅ **PASS — Ready for HITL Review** - ---- - -## 1. Bounce closure — SEC-1 (P2) - -### What was wrong - -`free-desktop-ports.cjs:26` selected kill targets with a substring predicate: - -```js -const needle = `:${port}`; -if (!local.endsWith(needle) && !local.includes(`${needle}`)) continue; -``` - -The `includes` disjunct made the whole guard permissive: `:5173` matched `:51730`–`:51739` -and `:15173`; `:3001` matched `:30010`–`:30019`. Those ranges sit inside the Windows -ephemeral port range (49152–65535 dynamic, plus 3xxxx in practice), so any `node.exe` -that happened to listen there — Cursor language servers, MCP servers — was a valid -kill candidate under the `node/node.exe` allowlist. The allowlist did not save it, -because the collateral processes *are* node. - -### What changed - -The predicate moved to a pure, exported, testable function in `desktop-shutdown.cjs:46-61`: - -```js -const m = /:(\d+)$/.exec(local); // anchored at end of local-address column only -if (!m) continue; -if (Number(m[1]) !== port) continue; // numeric equality — no substring -``` - -`free-desktop-ports.cjs:17-20` now delegates to it; the `line.includes(needle)` -pre-filter is gone entirely. - -### Why this closes it — structural argument - -The new selection set is a **strict subset** of the old one, so the fix cannot -widen the kill surface: - -> New selects a line ⟺ `cols[1]` ends in exactly `:` ⟹ `cols[1].endsWith(':'+port)` -> ⟹ the old `endsWith` disjunct also passed. Therefore new ⊆ old, and every element -> old-selected-but-new-skipped is a false positive that has been removed. - -Two further narrowings fall out of the rewrite: -- Only `cols[1]` (local address) is parsed. Previously an unanchored match could be - satisfied by text elsewhere on the line, including the **foreign** address column. -- The port is compared as a **number**, so `:517`, `:5174`, and `10.0.5173.1:80` - cannot alias `:5173`. - -### Verification — adversarial probe - -`node` is unavailable on PATH in this shell and in WSL (`NO_NODE_IN_WSL`), so the suite -could not be executed here. The predicate was instead transcribed faithfully into -PowerShell and driven against a corpus that **super-sets** the BE's own test fixture: - -| Local address | Port queried | Expected | Result | -|---|---|---|---| -| `0.0.0.0:5173` | 5173 | select | ✅ selected (2001) | -| `127.0.0.1:5173` | 5173 | select | ✅ selected (2002) | -| `[::1]:5173` | 5173 | select | ✅ selected (2003) | -| `[::]:5173` — *not in BE suite* | 5173 | select | ✅ selected (2004) | -| `[fe80::1%12]:5173` — *zone index, not in BE suite* | 5173 | select | ✅ selected (2005) | -| `127.0.0.1:51730` | 5173 | **spare** | ✅ spared | -| `127.0.0.1:51739` | 5173 | **spare** | ✅ spared | -| `127.0.0.1:15173` | 5173 | **spare** | ✅ spared | -| `127.0.0.1:5174` — *not in BE suite* | 5173 | **spare** | ✅ spared | -| `127.0.0.1:517` — *not in BE suite* | 5173 | **spare** | ✅ spared | -| `10.0.5173.1:80` — *octet alias, not in BE suite* | 5173 | **spare** | ✅ spared | -| foreign-addr `127.0.0.1:5173` on a `:9000` listener — *not in BE suite* | 5173 | **spare** | ✅ spared | -| `0.0.0.0:30015` | 3001 | **spare** | ✅ spared | -| `127.0.0.1:5173` **ESTABLISHED** | 5173 | **spare** | ✅ spared | -| `0.0.0.0:135` pid 0 | 135 | **spare** | ✅ spared | - -``` -legit targets MISSED (false negatives): none -collateral SELECTED (false positives): none -``` - -### Verification — live machine, no functional regression - -Real listeners on this host, old rule vs. new rule: - -``` -:5173 OLD => 127.0.0.1:5173(pid 34672) NEW => 127.0.0.1:5173(pid 34672) -:3001 OLD => 0.0.0.0:3001 [::]:3001 (pid 34544) NEW => 0.0.0.0:3001 [::]:3001 (pid 34544) -:6742 OLD => 0.0.0.0:6742(pid 34072) NEW => 0.0.0.0:6742(pid 34072) -``` - -All three legitimate Nightwolf targets — Vite, backend, OpenRGB — are still selected, -including the dual IPv4/IPv6 binding on 3001. **No false negatives introduced.** -No collateral node listener happens to occupy an ephemeral alias right now, which is -exactly why the original bug was intermittent rather than deterministic. - -### Regression lock - -`scripts/desktop-shutdown.test.cjs` adds 12 tests against `parseListeningPids`, -covering both directions (`:51730`/`:51739`/`:15173`/`:30010`/`:30015`/`:30019` spared; -`0.0.0.0:5173`, `127.0.0.1:5173`, `[::1]:5173`, `0.0.0.0:3001` selected) plus ESTABLISHED -and pid-0 filtering. Ledger records RED (`TypeError: parseListeningPids is not a function`, -11 pass / 12 fail) → GREEN (23/23, 97 ms). This satisfies the regression-test condition -attached to the bounce. - -**SEC-1 (P2) — CLOSED.** - ---- - -## 2. Second change reviewed — `killPid` (A03 Injection) - -`free-desktop-ports.cjs:34-37` was previously `execSync(\`taskkill /PID ${pid} /T /F\`)` — -a string command through a shell. It is now: - -```js -const { cmd, args } = killTreeArgs(pid); -execFileSync(cmd, args, { stdio: 'ignore', windowsHide: true }); -``` - -- **argv form, no shell** — the pid can never be re-parsed as command syntax, regardless - of what upstream validation does later. -- Shares `killTreeArgs` with `quitDesktop`, so the kill command has a single definition; - the two call sites can no longer drift apart. -- `execFileSync` still throws on nonzero exit, so the existing `try/catch` at - `free-desktop-ports.cjs:54-58` preserves the "process already gone" behaviour. - -Shell-usage sweep across both files: - -| Location | Call | Verdict | -|---|---|---| -| `free-desktop-ports.cjs:18` | `execSync('netstat -ano')` | constant string, no interpolation — safe | -| `free-desktop-ports.cjs:24` | `execSync(\`tasklist /FI "PID eq ${pid}" ...\`)` | interpolated — residual **P3 SEC-2**, see §4 | -| `free-desktop-ports.cjs:36` | `execFileSync(cmd, args)` | argv, no shell — ✅ | -| `desktop-shutdown.cjs:84` | `spawn(cmd, args, {windowsHide, stdio, detached:false})` | argv, no `shell: true` — ✅ | - -This partially closes the prior P3 SEC-2 (kill path done; `imageName` remains). - ---- - -## 3. OWASP Top 10 — delta scan on this diff - -| # | Category | Finding | -|---|---|---| -| A01 | Broken access control | No change. IPC surface unchanged (`preload.cjs` exposes only platform + window controls). | -| A02 | Cryptographic failures | N/A — no crypto, no secrets in scope. | -| A03 | Injection | **Improved.** Kill path moved to argv `execFileSync`. Residual `tasklist` interpolation is P3 and unreachable (§4). | -| A04 | Insecure design | **Improved.** Target selection is now a pure function with an explicit test suite; the allowlist (`KILL_TARGETS`) still gates on image name, so exact-port matching and name allowlisting are defence in depth. Cursor remains structurally unkillable — asserted by test at `desktop-shutdown.test.cjs:140`. | -| A05 | Misconfiguration | Dev-only `remote-debugging-port 9229` (`main.cjs:15`) unchanged — P3, dev-gated. Hardening intact: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` (`main.cjs:137-139`). | -| A06 | Vulnerable components | No new dependencies. Electron pinned `^33.2.1`, installed **33.4.11** — still the EOL line flagged as P3 SEC-6. | -| A07 | Auth failures | N/A — no auth in scope. | -| A08 | Integrity failures | No deserialization, no unsigned update path, no `eval`. | -| A09 | Logging failures | `console.log` on kill/skip decisions prints only pid, image name, port — no secrets or PII. Good audit trail for a destructive operation. | -| A10 | SSRF | `shell.openExternal(url)` at `main.cjs:165` unchanged — P3 SEC-4. | - -### Secrets scan - -``` -scripts/*.cjs, electron/*.cjs, package.json → clean, no secret-like literals -tracked .env files → backend/.env.example only (placeholder file, correct) -``` - -No keys, tokens, passwords, or private key material. **PASS.** - ---- - -## 4. Residual P3s — backlog, non-blocking - -Carried over from `sec-nw-12.md`; none gate this story. - -| ID | Location | Issue | Reachability | -|---|---|---|---| -| SEC-2 *(partial)* | `free-desktop-ports.cjs:24` | `tasklist` built by string interpolation | **Unreachable.** `pid` originates only from `parseListeningPids`, which validates `/^\d+$/` then returns `Number(pid)` (`desktop-shutdown.cjs:58`), so it is always a JS number. Latent sink; convert to `execFileSync` for symmetry with `killPid`. | -| SEC-3 | `desktop-shutdown.cjs:32`, `free-desktop-ports.cjs:18,24` | `taskkill`/`netstat`/`tasklist` invoked by bare name, resolved via `PATH` | Requires a pre-existing PATH-hijack, which is already game over. Pin to `%SystemRoot%\System32\` for defence in depth. | -| SEC-4 | `main.cjs:165` | `shell.openExternal(url)` with no scheme allowlist | Allowlist `https:`/`http:` before opening. | -| SEC-5 | `main.cjs:14-16` | `remote-debugging-port 9229` | Dev-gated by `isDev`; confirm it cannot be reached in a packaged build. | -| SEC-6 | `package.json:27` | Electron `^33.2.1`, installed 33.4.11 — EOL major | Plan a bump to a supported major. | -| SEC-7 | `package.json:28` | `wait-on` declared but unused after the runner rewrite | Remove — dependency hygiene. | - ---- - -## 5. Verdict - -The P2 that caused the bounce is closed, and closed correctly — not patched around. -The predicate was extracted into a pure function, tightened from substring to anchored -numeric equality, proven a strict subset of the old behaviour, locked by 12 regression -tests, and confirmed against an adversarial corpus that exceeds the BE's own fixture with -zero false positives and zero false negatives. The kill path was independently hardened -to argv execution. No new findings at P0/P1/P2. Secrets scan clean. - -**Ready for HITL Review: Story NW-12** — security gate PASS. Six P3s to backlog. -Re-review budget: 1 of 2 used. diff --git a/.harness/sprint-1/story-NW-12/evidence/sec-nw-12.md b/.harness/sprint-1/story-NW-12/evidence/sec-nw-12.md deleted file mode 100644 index 873eed3..0000000 --- a/.harness/sprint-1/story-NW-12/evidence/sec-nw-12.md +++ /dev/null @@ -1,140 +0,0 @@ -# Security Review — Story NW-12 - -**Date:** 2026-09-04 -**SEC:** independent gate (harness-sec subagent, fresh context) -**Entry signal:** `Approved for Architecture Review: Story NW-12` (QA), architecture review complete -**Scope:** shutdown/port-cleanup slice only — `scripts/desktop-shutdown.cjs`, `scripts/free-desktop-ports.cjs`, `scripts/run-desktop-electron.cjs`, `scripts/desktop-shutdown.test.cjs`, `electron/main.cjs`, `electron/preload.cjs`, `package.json` (`desktop` script + new devDeps). AC#3/AC#4 (typography) carry no security surface and were not reviewed. - -## Verdict: FAIL ✗ — bounce @BE - -One **P2** finding. The story's headline constraint is "must NEVER kill the wrong process," and the port-selection predicate in `free-desktop-ports.cjs:26` selects processes that are **not** on the target ports. `Cursor.exe` itself is safe (the image allowlist holds), but Cursor-spawned `node.exe` helpers are not. - -No P0/P1. No secrets. No injection reachable today. - ---- - -## P2 — SEC-1: Substring port match force-kills processes on unrelated ports - -**Location:** `scripts/free-desktop-ports.cjs:26` (inside `listeningPids`, lines 17–31) - -```js -const needle = `:${port}`; // :26 → ":5173" -// ... -if (!local.endsWith(needle) && !local.includes(`${needle}`)) continue; // line 26 -``` - -**Issue.** `endsWith` implies `includes`, so the disjunction collapses: the `endsWith` guard is dead and the effective test is **substring containment**. Any local address whose port merely *starts with* the target digits is selected. - -- `:5173` also matches local ports **51730–51739** -- `:3001` also matches local ports **30010–30019** -- `:6742` is safe only by accident (`67420` > 65535) - -51730–51739 sits inside the Windows default ephemeral range (49152–65535) — exactly where Node servers land when they bind with `listen(0)`. Language servers, MCP servers, debug adapters, and test runners all do this. - -**Reproduction** (predicate transcribed verbatim from line 26, run against synthetic `netstat -ano` lines): - -``` -needle=:5173 local=127.0.0.1:5173 pid=34672 -> SELECTED FOR KILL legit -needle=:5173 local=127.0.0.1:51730 pid=11111 -> SELECTED FOR KILL *** FALSE MATCH *** -needle=:5173 local=127.0.0.1:51739 pid=22222 -> SELECTED FOR KILL *** FALSE MATCH *** -needle=:3001 local=0.0.0.0:30015 pid=33333 -> SELECTED FOR KILL *** FALSE MATCH *** -``` - -A live `netstat` sweep of this machine found no listener currently inside the false-match ranges, and both real listeners (`node` :5173 pid 34672, `node` :3001 pid 34544) matched exactly. The bug is latent right now, not absent — occupancy of those 20 ports rotates. - -**Risk.** A falsely-matched pid whose image is `node.exe` clears the allowlist gate (`free-desktop-ports.cjs:57–62`) and is destroyed with `taskkill /PID /T /F` (`:46`) — force-kill, whole tree, no graceful shutdown, no confirmation. `freePorts()` runs on **three** paths per desktop session (`desktop` npm script pre-flight, `initiateQuit` at `electron/main.cjs:28–40`, and the post-exit sweep in `run-desktop-electron.cjs:65–68`), so exposure repeats several times per run. - -Concretely: closing the Nightwolf window can force-kill an unrelated `node.exe` — a Cursor MCP server, a language server, another project's dev server, or a running migration/test process — with whatever unsaved state it held. - -This directly undermines AC#2's stated guarantee. The test `KILL_TARGETS: Cursor is never in the kill allowlist` asserts the allowlist contents but never exercises the predicate that decides **which pids reach** the allowlist, so it gives false assurance on precisely the property it names. - -**Fix.** Delete the dead disjunct — the `endsWith` check alone is correct and already handles `0.0.0.0:5173`, `127.0.0.1:5173`, and `[::]:5173`: - -```js -if (!local.endsWith(needle)) continue; -``` - -**Also required — close the test gap.** `scripts/free-desktop-ports.cjs` has **no test file**. `desktop-shutdown.test.cjs` covers only `KILL_TARGETS`, `killTreeArgs`, and `quitDesktop`; the module that actually enumerates and kills by port is untested, which is why this survived the QA gate. Extract the netstat-line parser behind an injectable seam and add a RED test asserting `51730` / `30015` are **not** selected for `:5173` / `:3001`. - -**Route:** @BE. Re-enters at Gate 2 (QA), returns to Gate 4. - ---- - -## OWASP Top 10 Scan - -| # | Category | Result | -|---|---|---| -| A01 | Broken Access Control | **N/A** — no HTTP endpoints or authz surface added. IPC surface (`preload.cjs:1–10`) exposes only `minimize`/`maximize`/`close`, all parameterless; renderer cannot influence which pid is killed. | -| A02 | Cryptographic Failures | **N/A** — no crypto, no credentials, no data at rest introduced. | -| A03 | **Injection** | **✓ PASS** — see analysis below. | -| A04 | Insecure Design | **✗ see SEC-1** — force-kill by port with an over-permissive match and no dry-run/confirmation. | -| A05 | Security Misconfiguration | **✓ PASS** with P3 notes (SEC-4, SEC-5). `webPreferences` (`electron/main.cjs:135–140`) is correctly hardened: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`. Single-instance lock present. | -| A06 | Vulnerable Components | **P3** — see SEC-6. | -| A07 | Auth & Identity | **N/A** — no auth flow touched. | -| A08 | Integrity Failures | **✓ PASS** — no deserialization, no unverified remote script, no auto-update path in this diff. | -| A09 | Logging & Monitoring | **✓ PASS** — `free-desktop-ports.cjs:60,63` logs pid + image name only. No secrets or PII. | -| A10 | SSRF | **✓ PASS** — `run-desktop-electron.cjs:23` and `main.cjs:12` read `NIGHTWOLF_DEV_URL` from env (developer-controlled, not attacker-controlled) and default to `http://127.0.0.1:5173`. No user-supplied URL is fetched. | - -### A03 — Injection, detailed - -**Primary kill path — clean.** `desktop-shutdown.cjs:32` builds `{ cmd: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] }` and `quitDesktop` (`:52–68`) passes it to `spawn` with **no `shell: true`** (`:56–60`), so argv goes to `CreateProcess` unparsed by any shell. The pid source is `backendProcess?.pid` (`electron/main.cjs:32`) — an OS-assigned integer from Node's own `spawn`, never renderer- or user-supplied. Safe on two independent grounds. - -**Secondary path — no reachable injection, but a latent sink (P3, SEC-2).** `free-desktop-ports.cjs` interpolates into shell strings: - -- `:35` — `` execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`) `` -- `:46` — `` execSync(`taskkill /PID ${pid} /T /F`) `` - -`execSync` routes through `cmd.exe`. Injection is **not reachable today**: the only caller is `freePorts`, and every pid is filtered by `/^\d+$/.test(pid)` then coerced with `Number()` at `:28` before reaching either function. Both are module-private and take no external input. This is a hardening item, not a live vulnerability — but it is one refactor away from becoming one. Prefer `execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'])` and `execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'])`, which removes the sink class entirely. - -`execSync('netstat -ano')` (`:18`) is a static string — no interpolation. - ---- - -## Secrets Scan - -Pattern sweep (`api[_-]?key|secret|password|token|bearer|AKIA|ghp_|sk-|AIza|xox[baprs]-|PRIVATE KEY|credential`, case-insensitive) across `desktop-shutdown.cjs`, `free-desktop-ports.cjs`, `run-desktop-electron.cjs`, `desktop-shutdown.test.cjs`, `electron/main.cjs`, `electron/preload.cjs`: - -``` -(no matches) -``` - -- No `.env` tracked in git — `git ls-files` returns only `backend/.env.example`. ✓ -- `.gitignore` diff adds `bin/OpenRGB/VERSION.json` and `brand/derived/` — no secret-handling regression. ✓ -- No secrets in commit messages — **nothing committed** (all NW-12 files untracked, per HITL instruction). ✓ -- `electron/main.cjs:99` passes `{ ...process.env, FORCE_COLOR: '1' }` to the backend child. Standard for a spawned dev server; no new exposure (the child already inherits the same trust domain). - -**Result: clean.** - ---- - -## P3 findings — Product Backlog, non-blocking - -**SEC-2 — Shell interpolation in `execSync` (`free-desktop-ports.cjs:35,46`).** Latent command-injection sink, currently unreachable. Switch to `execFileSync` with argv arrays. Detail in A03 above. - -**SEC-3 — System binaries resolved via `PATH`.** `taskkill` (`desktop-shutdown.cjs:32`), `netstat` (`free-desktop-ports.cjs:18`), and `tasklist` (`:35`) resolve through `PATH` rather than an absolute path. An attacker with write access to any earlier `PATH` directory gains code execution when the app quits. Low severity — that prerequisite already implies broader compromise, and `System32` precedes user paths by default. Harden with `path.join(process.env.SystemRoot, 'System32', 'taskkill.exe')`. - -**SEC-4 — `shell.openExternal` with no protocol allowlist (`electron/main.cjs:165`).** *Pre-existing, outside the NW-12 diff.* `setWindowOpenHandler` forwards any renderer-initiated `window.open` URL straight to the OS protocol handler. Renderer hardening (`sandbox`, `contextIsolation`) makes this hard to reach, but an XSS in the dashboard could invoke `file:` or a custom protocol handler. Standard Electron hardening: allowlist `http:`/`https:` before calling `openExternal`. - -**SEC-5 — `remote-debugging-port 9229` (`electron/main.cjs:14–16`).** Correctly gated behind `isDev` (`!app.isPackaged`), so it never ships. Informational: while running, any local process — or a web page via DNS-rebinding against CDP — can execute arbitrary JS in the app context. Bind-to-loopback is already the CDP default; no change required for a dev-only switch. - -**SEC-6 — Electron 33.2.1 pinned to an EOL major (`package.json:27`).** Electron supports only the latest three majors; 33.x (Nov 2024) no longer receives Chromium security backports. Exposure is bounded here — the renderer loads localhost only, sandboxed, with context isolation — so this is backlog, not a gate block. Plan an upgrade to a supported major before any packaged release. - -**SEC-7 — `wait-on ^8.0.1` is now an unused dependency (`package.json:28`).** NW-12 replaced the `npx wait-on` fragment with a native `http.get` poller (`run-desktop-electron.cjs:27–45`); the only remaining reference is a comment at `:6`. Dead dependencies are unnecessary supply-chain surface. Remove it. - ---- - -## Informational — not defects - -**Scope of authority on port 3001.** `ensureBackend` (`electron/main.cjs:109–113`) skips `startBackend()` when the port is already open, leaving `backendProcess === null`. `quitDesktop` then skips the `taskkill` tree kill (`desktop-shutdown.cjs:53`) and `freePorts` reclaims 3001 by port instead — so Electron terminates a backend it did not spawn, e.g. one the developer started from a terminal. AC#1 explicitly requires 3001 to be free after close, so this is per-spec. Documented so it is a recorded decision rather than a surprise. - -**`taskkill /T` direction.** `/T` kills descendants only, never ancestors. The backend tree kill therefore cannot walk up into a parent shell or into Cursor. Confirmed the backend is spawned with `shell: true` on Windows (`electron/main.cjs:97`), so `backendProcess.pid` is `cmd.exe` and `/T` is required to reach npm → node. Correct as written. - -**DoD gap (QA/ARCH concern, not security).** `package.json` has `test:update`, `test:engine`, and `test:type-roles`, but no `test:shutdown` for `scripts/desktop-shutdown.test.cjs`. The DoD calls for the new `node --test scripts/*.test.cjs` suites to be runnable. Flagging for @BE to fold in alongside the SEC-1 fix. - ---- - -## Exit State - -**Security Gate Failure — Story NW-12: P2, substring port match in `free-desktop-ports.cjs:26` force-kills `node.exe` processes on ports 51730–51739 / 30010–30019 → @BE** - -Re-review budget: 1 of 2 used. On resubmission SEC verifies (a) the predicate change at `:26`, and (b) a RED→GREEN test proving `51730` and `30015` are not selected. diff --git a/.harness/sprint-1/story-NW-12/ledger.md b/.harness/sprint-1/story-NW-12/ledger.md deleted file mode 100644 index b129e12..0000000 --- a/.harness/sprint-1/story-NW-12/ledger.md +++ /dev/null @@ -1,93 +0,0 @@ -# Ledger — Story NW-12 - -## Entries - -- 2026-09-04 — PO/SM — Sprint 1 Goal confirmado pelo pedido de implementação. Story NW-12 In Progress. FE owns AC#3–4. BE owns AC#1–2. Não commitar. - -## Seams - -| AC | Seam | Test file | -|---|---|---| -| AC#1 | `scripts/desktop-shutdown.cjs` via `electron/main.cjs` `window:close` | `scripts/desktop-shutdown.test.cjs` | -| AC#2 | shutdown + `package.json` `desktop` cleanup / `free-desktop-ports.cjs` | `scripts/desktop-shutdown.test.cjs` | -| AC#3 | `.nw-display` + empty state `Dashboard.tsx` | `scripts/type-roles.test.cjs` | -| AC#4 | `Titlebar.tsx` + `BrandMark.tsx` + `.nw-display` | `scripts/type-roles.test.cjs` | - -## TDD slices - -_(implementers append RED/GREEN here)_ - ---- - -**TDD slice — AC#1 (BE) · killTreeArgs + KILL_TARGETS + quitDesktop unit tests** -- Seams: `scripts/desktop-shutdown.cjs` via `scripts/desktop-shutdown.test.cjs` -- RED: `node --test scripts/desktop-shutdown.test.cjs` — `Error: Cannot find module './desktop-shutdown.cjs'` (module did not exist yet) -- GREEN: same 11 tests PASS (`node --test scripts/desktop-shutdown.test.cjs` — 11/11 pass, 87 ms) - - `killTreeArgs` returns `taskkill` with `/PID`, `/T`, `/F` - - `KILL_TARGETS` covers exactly ports 5173/3001/6742 with correct name sets; Cursor absent - - `quitDesktop` with backendPid 9999: spawns `taskkill /PID 9999 /T /F`, calls freePorts, calls exit(0) - - `quitDesktop` with null backendPid: skips spawn, calls freePorts, calls exit(0) - -**TDD slice — AC#2 (BE) · electron/main.cjs wiring + package.json desktop script** -- Seams: `electron/main.cjs` (IPC + BrowserWindow close → `initiateQuit`), `package.json` desktop script → `run-desktop-electron.cjs` -- Changes (integration wiring — no additional unit tests; covered by AC#1 tests above): - - `electron/main.cjs`: imports `quitDesktop`/`freePorts`; `quitting` flag; `initiateQuit()`; IPC `window:close` → `initiateQuit()`; BrowserWindow `close` event → `initiateQuit()`; `window-all-closed` → `initiateQuit()`; removed old `before-quit` handler - - `scripts/run-desktop-electron.cjs` (new): waits Vite → spawns Electron → unconditionally runs `free-desktop-ports.cjs` → exits with Electron code - - `scripts/free-desktop-ports.cjs`: refactored to import `KILL_TARGETS` from `desktop-shutdown.cjs`; exports `freePorts()`; retains standalone `require.main` execution - - `package.json` desktop: changed inner Electron command from `npx wait-on … && npx electron .` to `node scripts/run-desktop-electron.cjs` -- GREEN: `node --test scripts/desktop-shutdown.test.cjs` — 11/11 pass (no regression) - ---- - -**TDD slice — AC#3 (FE) · .nw-display line-height** -- Seams: `frontend/src/index.css` via `scripts/type-roles.test.cjs` -- RED: `AC#3 · .nw-display line-height >= 1.25` — `line-height: 1.1 — need >= 1.25 so the Syne 700 descender at 22 px (~4.6 px below baseline) stays inside the line box` -- GREEN: same test PASS (`node --test scripts/type-roles.test.cjs` — 6/6 pass) - -**TDD slice — AC#3 (FE) · empty-state title .nw-display class** -- Seams: `frontend/src/components/Dashboard.tsx` via `scripts/type-roles.test.cjs` -- RED: already green at baseline (title carried `.nw-display`; no change needed) -- GREEN: `AC#3 · empty-state title "Nada ligado ainda" carries .nw-display` PASS - -**TDD slice — AC#3 (FE) · empty-state subtitle gap** -- Seams: `frontend/src/components/Dashboard.tsx` via `scripts/type-roles.test.cjs` -- RED: already green at baseline (`mt-2` = 8 px satisfies the gap; no change needed) -- GREEN: `AC#3 · empty-state subtitle has mt >= 8 px` PASS - -**TDD slice — AC#4 (FE) · Titlebar header overflow-visible** -- Seams: `frontend/src/components/Titlebar.tsx` via `scripts/type-roles.test.cjs` -- RED: already green at baseline (`overflow-visible` already present; no change needed) -- GREEN: `AC#4 · Titlebar
has overflow-visible` PASS - -**TDD slice — AC#4 (FE) · Titlebar header min-height >= 44px** -- Seams: `frontend/src/components/Titlebar.tsx` via `scripts/type-roles.test.cjs` -- RED: `AC#4 · Titlebar
min-height >= 44 px` — `h-9 (36px) does not satisfy >= 44px WCAG AA` -- Fix: changed `h-9` → `h-11` (44px) in Titlebar.tsx -- GREEN: same test PASS (`node --test scripts/type-roles.test.cjs` — 6/6 pass) - -**TDD slice — AC#4 (FE) · BrandMark wrapper overflow-visible** -- Seams: `frontend/src/components/BrandMark.tsx` via `scripts/type-roles.test.cjs` -- RED: `AC#4 · BrandMark wrapper span has overflow-visible` — `wrapper span missing overflow-visible; ember glow clipped` -- Fix: added `overflow-visible` to the wrapper `` in BrandMark.tsx -- GREEN: same test PASS (`node --test scripts/type-roles.test.cjs` — 6/6 pass) - ---- - -**TDD slice — BOUNCE fix · parseListeningPids exact-port matching** -- Bounce root: `listeningPids` in `free-desktop-ports.cjs` used substring match — - `:5173` matched `:51730`–`:51739` and `:15173`; `:3001` matched `:30010`–`:30019`. - Collateral node.exe PIDs (Cursor language servers, MCP) were being killed. -- Seams: `scripts/desktop-shutdown.cjs` (new export `parseListeningPids`) via `scripts/desktop-shutdown.test.cjs` -- RED: 12 new tests fail — `TypeError: parseListeningPids is not a function` - (`node --test scripts/desktop-shutdown.test.cjs` — 11 pass, 12 fail) -- Fix applied: - 1. Added `parseListeningPids(netstatOutput, port)` to `desktop-shutdown.cjs`. - Uses `/:(\d+)$/.exec(local)` to extract the numeric port, then compares - `Number(match) === port` — exact equality, no substring. IPv4 and IPv6 - (`[::1]:5173`, `:::5173`) are handled by the same regex. ESTABLISHED lines - and pid 0 continue to be filtered out. - 2. Replaced `listeningPids` body in `free-desktop-ports.cjs` to delegate to - `parseListeningPids(execSync('netstat -ano', ...), port)`. - 3. Fixed `killPid` in `free-desktop-ports.cjs` to use `killTreeArgs` + `execFileSync` - instead of `execSync(\`taskkill /PID ${pid}\`)`. -- GREEN: all 23 tests PASS (`node --test scripts/desktop-shutdown.test.cjs` — 23/23, 97 ms) diff --git a/.harness/sprint-1/story-NW-12/story.md b/.harness/sprint-1/story-NW-12/story.md deleted file mode 100644 index 511f8c9..0000000 --- a/.harness/sprint-1/story-NW-12/story.md +++ /dev/null @@ -1,32 +0,0 @@ -# Story NW-12 — Fechar encerra o desktop; Syne não recorta o g - -As an operador do Nightwolf RGB, -I want o X da janela matar o app inteiro, e os títulos em Syne mostrarem a perna do “g”, -So that não fique Vite/backend/OpenRGB zumbi na 5173, e o wordmark/empty state não nasçam cortados. - -## Acceptance Criteria -1. Clicar Fechar (X) encerra o processo Electron; o backend (porta 3001) e o OpenRGB (porta 6742) não continuam escutando. -2. Depois desse Fechar, a porta 5173 não fica com um `node` Vite residual (o `concurrently -k` ou o cleanup pós-exit libera). -3. O título de empty state “Nada ligado ainda” mostra a perna inteira do “g”; a frase do SDK fica abaixo, sem sobrepor o glifo. -4. No header, o ícone com glow e o wordmark “Nightwolf RGB” mostram a perna inteira do “g” (e o glow não é recortado pela barra). - -## Testing seams -- AC#1 → módulo `scripts/desktop-shutdown.cjs` (`quitDesktop` / kill da árvore do backend) → `scripts/desktop-shutdown.test.cjs` (node:test). `electron/main.cjs` só chama esse módulo no IPC `window:close` e no `close` da BrowserWindow. -- AC#2 → o mesmo módulo + o comando `desktop` em `package.json` (após o Electron sair, roda `free-desktop-ports`) → teste que a sequência de shutdown inclui liberar 5173/3001/6742 só para `node`/`OpenRGB`, nunca `Cursor`. -- AC#3 → contrato de tipo `.nw-display` em `frontend/src/index.css` + empty state em `Dashboard.tsx` → `scripts/type-roles.test.cjs` (lê CSS/TSX; line-height e gap que cabem o descendente da Syne; subtítulo não cobre o título). -- AC#4 → `Titlebar.tsx` + `BrandMark.tsx` + `.nw-display` → o mesmo `scripts/type-roles.test.cjs` (header `overflow: visible`, altura mínima da barra, glow não clipado). - -## Definition of Done -- [ ] Feature code complete (não commitar — HITL pede commit à parte) -- [ ] TDD ledger: RED/GREEN recorded per AC slice (see `tdd` skill) -- [ ] Unit tests written (coverage ≥ 80% for new code) -- [ ] Integration tests passing (`npm run test:engine`, `npm run test:update`, novos `node --test scripts/*.test.cjs`) -- [ ] Documentation updated (if user-facing feature) — N/A salvo `design.md` line-height se o token Display mudar -- [ ] Security review complete -- [ ] PO accepted in Sprint Review - -## Story Points -5 - -## Priority -P1 High diff --git a/README.md b/README.md index 00073d9..92aff26 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ Browser at `http://localhost:5173` is a fallback for layout work, not the produc ## 📖 Documentation +Product docs live under [`docs/`](./docs/README.md): [setup](./docs/setup.md), [development](./docs/development.md), [architecture](./docs/architecture.md), [technical](./docs/technical.md), [ADRs](./docs/adr/), and the locked design system in [`design.md`](./design.md). + ### API Endpoints #### Status & Connection diff --git a/design.md b/design.md index 1216eb6..3cf352c 100644 --- a/design.md +++ b/design.md @@ -92,7 +92,7 @@ Desktop 1440×900. Spacing is dashboard-dense (8–24px), not marketing (48–96 **Landing** (`docs/index.html`, GitHub Pages): same three families and the locked graphite / ink tokens. No Inter, Orbitron, or purple/cyan hologram. - Macrostructure: Photographic. Genre: atmospheric. Tone: cinematic. -- Nav: lighting-console mast (IconRail identity) — full-bleed graphite bar, square cells, `--live` 1px bottom edge and left spine on the current item, icon+text Studio / Luz / Efeitos / Cenas. Not a pill. Mobile: square fader toggle opens a left rail popover. Footer: Ft5 statement (`MIT · motor OpenRGB`, not Windows-only). +- Nav: lighting-console mast (IconRail identity) — full-bleed graphite bar, square cells, `--live` 1px bottom edge and left spine on the current item, icon+text Studio / Luz / Efeitos / Cenas. Not a pill. Mobile: square fader toggle opens a left rail popover. Footer: thin `site-foot` row — `Nightwolf RGB` + `MIT · OpenRGB · GitHub` (no hero echo, not Windows-only). No pre-footer “Código aberto / Fork” band (GitHub lives in the footer; clone/dev commands stay in docs). - CTA is the install action: both mast and hero open the same native popover/sheet (PowerShell `irm | iex`, Git Bash `curl | bash`, código-fonte). Square console chip — inset graphite, ink type, 2px `--live` fader mark, hairline live edge. Hover tracks `--live`. Pressed is inset. No zip. No scroll-to-download band. - Folds are real Studio / Luz / Efeitos / Cenas captures shown whole (1920×1080, `object-fit: contain`) inside a lit well — never cropped, never ken-burned. No motherboard, SKU, or capture-date on the landing. - `--live` on the landing is driven by cursor X across `WASH_PRESETS` (`#c9897a` `#d45c5c` `#c4a35a` `#6f9e6a` `#4a7ea8` `#f3ead8`). A follow-spot and chrome glow track `--live`. Until the pointer moves, `--live` stays `#ff4d8d`. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e83c467 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Documentation + +Canonical product docs for Nightwolf RGB. Session scratch, investigation scoreboards, and ephemeral review evidence do not live in this tree — durable work belongs in GitHub Issues / ADRs. + +| Document | Purpose | +|---|---| +| [setup.md](./setup.md) | One-time install (OpenRGB, deps, first run) | +| [development.md](./development.md) | Local desktop / backend / Vite workflow | +| [architecture.md](./architecture.md) | Stack, protocols, cleanup engine | +| [technical.md](./technical.md) | Deep technical reference | +| [adr/](./adr/) | Architecture decision records | +| [`../design.md`](../design.md) | Locked design system (tokens, type, IA chrome) | + +Public marketing site sources: `index.html`, `index.md`, `llms.txt` (GitHub Pages). diff --git a/docs/_retired-brand/logo.png b/docs/_retired-brand/logo.png deleted file mode 100644 index 6a7a792..0000000 Binary files a/docs/_retired-brand/logo.png and /dev/null differ diff --git a/docs/_retired-brand/nav_logo.png b/docs/_retired-brand/nav_logo.png deleted file mode 100644 index f970eb9..0000000 Binary files a/docs/_retired-brand/nav_logo.png and /dev/null differ diff --git a/docs/adr/0001-three-surface-effects-ia.md b/docs/adr/0001-three-surface-effects-ia.md new file mode 100644 index 0000000..25ce81e --- /dev/null +++ b/docs/adr/0001-three-surface-effects-ia.md @@ -0,0 +1,6 @@ +# Three-surface effects IA + +Explorar, Efeitos, and Biblioteca are separate rail destinations. Discover is an honest catalog browse surface; Efeitos is the live console; Biblioteca is installed-only (empty state CTAs to Explorar, no Direct catalog mirror). Selection updates UI preview only; Apply drives hardware and the installed library. + +**Status:** accepted +**Date:** 2026-09-10 diff --git a/HOW_IT_WORKS.md b/docs/architecture.md similarity index 100% rename from HOW_IT_WORKS.md rename to docs/architecture.md diff --git a/DEV.md b/docs/development.md similarity index 100% rename from DEV.md rename to docs/development.md diff --git a/docs/index.html b/docs/index.html index 9b56891..dfb27bb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -71,7 +71,7 @@ - - -
- -

Nightwolf RGB · Gauntlet Progress

-

8 set 2026 · round 1 completo · landing reconstruída

- - -

Round 1 — Veredictos

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CategoriaResultadoOponenteEvidência / Gap
Feature copyNossoiCUENossa lista de features venceu. Cópia mais honesta e específica.
Repo pathNossoGitHub DesktopCaminho de clone/run mais claro que o competidor.
Primeira tela mobileBar (Signal)Signal.org mobile - Product visual era um thumbnail; fold lia como pilha de texto. -
Screenshot abaixo do botão Baixar era um retângulo pequeno, chrome ilegível.
-
Product imagery / heroBar (Signal)Signal.org - Studio shot era um cartão postal em fundo preto — janela do app não era o objeto central. -
Evidência: imagem do Studio como thumbnail, chrome ilegível.
-
- - -

Mudanças aplicadas · rebuild 8 set 2026

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ItemStatusDetalhe
Hero proof — mobile-firstDoneorder: -1 em .proof abaixo de 60 rem — screenshot aparece antes do copy em 375 px.
Hero proof — desktopDonewidth: 112% em coluna 1.2 fr, overflow visual para o topo-direito da coluna.
Links não-rosasDonea { color: var(--ink-mute) }--live removido de todos os links.
Strip labels não-rosasDone.strip-list dt { color: var(--ink) } — feature labels em ink, não live.
Wash chips — paleta de predefiniçõesDone6 swatches de cor (terracota, vermelho, ouro, verde, azul, branco) como dados do app.
Download band — CTA únicoDoneApenas um botão primário "Baixar" (--live). npm install movido para seção Código aberto.
Clone command — Código abertoDonenpm install + npm run desktop em <pre> dentro da seção Código aberto, separado visualmente.
Copy pt-BR marketingDoneHero, strip, features, download, Código aberto e footer reescritos. Feel: Signal + iCUE, honesto.
Screenshots recapturadasPendente - App não estava rodando durante o rebuild. PNGs não existem em docs/images/. -
Precisam ser capturados manualmente com app rodando no Windows + OpenRGB SDK up.
-
- - -

Guia de captura de screenshots

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ArquivoVistaCor / estadoTamanho alvo
docs/images/studio.pngStudioWash azul (#4a7ea8), SDK ligada, B550M GAMING X WIFI6 no palco.1600 × 900
docs/images/luz.pngLuzSwatch verde (#6f9e6a) selecionado, brilho 98, modos hardware visíveis.1600 × 900
docs/images/efeitos.pngEfeitosRainbow ativo — gradiente multi-cor no device, slider velocidade 50.1600 × 900
docs/images/cenas.pngCenasVista vazia (estado inicial), botão "Nova cena" visível.1600 × 900
- - -

Round 2 — Escopo sugerido

-
    -
  • Recapturar screenshots com as washes corretas e validar que o hero shot lê bem em 375 px.
  • -
  • Comparar layout do hero (screenshot-first mobile) com Signal.org mobile.
  • -
  • Avaliar se wash chips precisam de texto explicativo para leitores de tela.
  • -
  • Medir Lighthouse (perf + a11y) após screenshots serem adicionadas.
  • -
- -
- Nota: gauntlet-progress.html é artefato interno. Não entra no docs/ e não é publicado via GitHub Pages. -
- -
- - diff --git a/gauntlet-signalrgb-progress.html b/gauntlet-signalrgb-progress.html deleted file mode 100644 index ee302c9..0000000 --- a/gauntlet-signalrgb-progress.html +++ /dev/null @@ -1,191 +0,0 @@ - - - - - - - Nightwolf · Gauntlet SignalRGB - - - -
-

Nightwolf · Gauntlet SignalRGB

-

10 set 2026 · Library R5 A wins · canvas PAUSED (HITL) · Discover settled · handoff pronto

- -

Bars (reais)

-
-
- SignalRGB Marketplace Discover -
B · Marketplace Discover
-
-
- SignalRGB Discover in-app -
B · Discover in-app (docs)
-
-
- SignalRGB Layout canvas -
A · Layout / canvas espacial
-
-
- -

Peças

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PeçaRoundsVeredictoGap atual
Canvas mapping10pausedR10 B: stall lit-density ↔ field-through (R7–10). Sem R11 até decisão HITL.
Discover browse / search7settledSpotlight chrome ok (R7); depth de loja → Library (fechada). Sem Bundles falsos.
Effect preview fidelity4bar winsProcedural SVG vs foto — limite honesto.
Install / apply one-click3nw winsAplicar → Biblioteca 1ª classe (acoplado a Library R5 A).
Library of installed5nw winsR5 A: destino + seções + Filtros + busca. Peça fechada (sem Free/Bundles).
- -

Próximos passos (HITL)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#AçãoBloqueia
0Efeitos + Biblioteca: inventariar/corrigir muitos botões e filtros que não funcionamUX quebrada (relato HITL)
1Canvas: aceitar R10 · JS per-LED sampling · ou forçar R11+Qualquer round novo de canvas
2Após decisão: atualizar veredicto canvas nesta página
3Opcional: Effect preview (bar ainda ganha — só se pedir push honesto)
4Commit WIP só se HITL pedir (working tree hoje)
- -
- Exit = crítico cego escolhe Nightwolf. Sem teto de rounds. - Honestidade: sem loja falsa Free/Bundles; catalog = Direct + OpenRGB plugins. - Handoff agente: D:\Development\handoffs\2026-09-10-nightwolf-gauntlet-signalrgb.md - Refresh automático a cada 30s. -
-
- - diff --git a/package.json b/package.json index b87c54c..706888d 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "secrets-scan": "node scripts/secrets-scan.cjs", "pack:release": "node scripts/pack-release.cjs", "test:windows-identity": "node --test scripts/windows-app-identity.test.cjs", - "test:ci": "node --test scripts/lighting-engine.test.cjs scripts/canvas-layout.test.cjs scripts/type-roles.test.cjs scripts/effect-workbench-state.test.cjs scripts/chrome-ia.test.cjs scripts/openrgb-parity.test.cjs scripts/windows-app-identity.test.cjs scripts/desktop-shutdown.test.cjs scripts/desktop-restore.test.cjs scripts/desktop-boot.test.cjs scripts/pack-release.test.cjs scripts/secrets-scan.test.cjs", + "test:ci": "node --test scripts/lighting-engine.test.cjs scripts/canvas-layout.test.cjs scripts/type-roles.test.cjs scripts/effect-workbench-state.test.cjs scripts/chrome-ia.test.cjs scripts/openrgb-parity.test.cjs scripts/windows-app-identity.test.cjs scripts/desktop-shutdown.test.cjs scripts/desktop-restore.test.cjs scripts/desktop-boot.test.cjs scripts/pack-release.test.cjs scripts/secrets-scan.test.cjs scripts/landing-pages.test.cjs", + "test:landing": "node --test scripts/landing-pages.test.cjs", "test:canvas": "node --test scripts/canvas-layout.test.cjs", "brand": "node scripts/update-brand.cjs", "update": "node scripts/update.cjs", diff --git a/scripts/ci-gates.cjs b/scripts/ci-gates.cjs index e14317e..0dc3527 100644 --- a/scripts/ci-gates.cjs +++ b/scripts/ci-gates.cjs @@ -45,12 +45,16 @@ const tests = [ 'scripts/lighting-engine.test.cjs', 'scripts/canvas-layout.test.cjs', 'scripts/type-roles.test.cjs', + 'scripts/effect-workbench-state.test.cjs', 'scripts/chrome-ia.test.cjs', 'scripts/openrgb-parity.test.cjs', 'scripts/windows-app-identity.test.cjs', 'scripts/desktop-shutdown.test.cjs', + 'scripts/desktop-restore.test.cjs', + 'scripts/desktop-boot.test.cjs', 'scripts/pack-release.test.cjs', 'scripts/secrets-scan.test.cjs', + 'scripts/landing-pages.test.cjs', ]; run('test:unit', node, ['--test', ...tests]); run('test:update-safe', node, [ diff --git a/scripts/landing-pages.cjs b/scripts/landing-pages.cjs new file mode 100644 index 0000000..4e39e38 --- /dev/null +++ b/scripts/landing-pages.cjs @@ -0,0 +1,156 @@ +/** + * Landing Pages contract — inspect docs/index.html without a browser. + * Used by unit, regression, e2e-structure, and mutation tests. + */ +'use strict'; + +/** + * @param {string} html + * @returns {{ + * heroTaglineCount: number, + * hasInvolvedSection: boolean, + * hasInvolvedCss: boolean, + * footerHtml: string, + * footerEchoesHero: boolean, + * footerHasBrand: boolean, + * footerHasMit: boolean, + * footerHasOpenRgb: boolean, + * footerHasGithub: boolean, + * footerWindowsOnly: boolean, + * footerUsesStatementDisplay: boolean, + * installSheetPresent: boolean, + * baixarOpensInstall: number, + * cloneDevCommandsInPage: boolean, + * }} + */ +function inspectLanding(html) { + const footerMatch = html.match(/]*>([\s\S]*?)<\/footer>/i); + const footerHtml = footerMatch ? footerMatch[0] : ''; + const tagline = /O RGB do PC, no seu controle\./g; + + return { + heroTaglineCount: (html.match(tagline) || []).length, + hasInvolvedSection: + /]*\binvolved\b/.test(html) || /id=["']involved-title["']/.test(html), + hasInvolvedCss: /\.involved\b/.test(html) || /\.involved-mark\b/.test(html), + footerHtml, + footerEchoesHero: /O RGB do PC, no seu controle\./.test(footerHtml), + footerHasBrand: /Nightwolf RGB/.test(footerHtml), + footerHasMit: /\bMIT\b/.test(footerHtml), + footerHasOpenRgb: /openrgb\.org/i.test(footerHtml), + footerHasGithub: /github\.com\/klebertiko\/NightwolfRGB/.test(footerHtml), + footerWindowsOnly: /Windows-only|s[oó] Windows|apenas Windows/i.test(footerHtml), + footerUsesStatementDisplay: + /class=["'][^"']*foot-stmt__line/.test(footerHtml) || + /foot-stmt__line/.test(footerHtml), + installSheetPresent: /id=["']install["']/.test(html) && /popover=["']auto["']/.test(html), + baixarOpensInstall: (html.match(/popovertarget=["']install["']/g) || []).length, + cloneDevCommandsInPage: /npm run desktop/.test(html), + }; +} + +/** + * @param {string} html + * @returns {string[]} + */ +function landingContractErrors(html) { + const i = inspectLanding(html); + const errors = []; + + if (i.hasInvolvedSection) { + errors.push('pre-footer involved section must be removed (duplicates CTA/source pitch)'); + } + if (i.hasInvolvedCss) { + errors.push('involved CSS must be removed with the section'); + } + if (i.cloneDevCommandsInPage) { + errors.push('clone/dev npm commands belong in docs, not the marketing landing'); + } + if (i.footerEchoesHero) { + errors.push('footer must not repeat the hero tagline'); + } + if (i.footerUsesStatementDisplay) { + errors.push('footer must not use the large Ft5 statement display line'); + } + if (i.heroTaglineCount !== 1) { + errors.push(`hero tagline must appear exactly once (got ${i.heroTaglineCount})`); + } + if (!i.footerHasBrand) errors.push('footer must name Nightwolf RGB'); + if (!i.footerHasMit) errors.push('footer must include MIT'); + if (!i.footerHasOpenRgb) errors.push('footer must link OpenRGB'); + if (!i.footerHasGithub) errors.push('footer must link the GitHub repo'); + if (i.footerWindowsOnly) errors.push('footer must not lock the product to Windows'); + if (!i.installSheetPresent) errors.push('install sheet popover must remain'); + if (i.baixarOpensInstall < 2) { + errors.push('Baixar must open the install sheet from mast and hero'); + } + + return errors; +} + +/** + * @param {string} html + */ +function assertLandingContract(html) { + const errors = landingContractErrors(html); + if (errors.length) { + const err = new Error(errors.join('\n')); + err.errors = errors; + throw err; + } + return inspectLanding(html); +} + +/** + * Apply a named mutation for kill-score checks. + * @param {string} html + * @param {'readd-involved'|'hero-echo-footer'|'drop-mit'|'windows-lock'} name + */ +function mutateLanding(html, name) { + switch (name) { + case 'readd-involved': + return html.replace( + '', + `
+
+

Código aberto. Fork à vontade.

+
npm run desktop
+
+
+ `, + ); + case 'hero-echo-footer': + return html.replace( + /]*>[\s\S]*?<\/footer>/i, + `
+
+

O RGB do PC, no seu controle.

+
+ MIT · motor OpenRGB + GitHub +
+
+
`, + ); + case 'drop-mit': + return html.replace(/]*>[\s\S]*?<\/footer>/i, (block) => + block.replace(/\bMIT\b/g, 'Proprietary'), + ); + case 'windows-lock': + return html.replace(/]*>[\s\S]*?<\/footer>/i, (block) => + block.replace( + /<\/footer>/i, + `Windows-only`, + ), + ); + default: + throw new Error(`unknown mutation: ${name}`); + } +} + +module.exports = { + inspectLanding, + landingContractErrors, + assertLandingContract, + mutateLanding, +}; diff --git a/scripts/landing-pages.test.cjs b/scripts/landing-pages.test.cjs new file mode 100644 index 0000000..29096a1 --- /dev/null +++ b/scripts/landing-pages.test.cjs @@ -0,0 +1,146 @@ +/** + * Landing Pages — unit, regression, e2e-structure, mutation. + * + * Run: node --test scripts/landing-pages.test.cjs + */ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + inspectLanding, + landingContractErrors, + assertLandingContract, + mutateLanding, +} = require('./landing-pages.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const LANDING = path.join(ROOT, 'docs', 'index.html'); + +function readLanding() { + return fs.readFileSync(LANDING, 'utf8'); +} + +const FIXTURE_GOOD = ` +
+

O RGB do PC, no seu controle.

+ + +
+
+ +`; + +/* ── unit ── */ + +test('unit: good fixture passes contract', () => { + assert.doesNotThrow(() => assertLandingContract(FIXTURE_GOOD)); + const i = inspectLanding(FIXTURE_GOOD); + assert.equal(i.heroTaglineCount, 1); + assert.equal(i.hasInvolvedSection, false); + assert.equal(i.footerEchoesHero, false); + assert.equal(i.footerHasBrand, true); +}); + +test('unit: involved section is a contract error', () => { + const bad = FIXTURE_GOOD.replace( + '', + '

x

', + ); + const errors = landingContractErrors(bad); + assert.ok(errors.some((e) => /involved section/i.test(e))); +}); + +test('unit: hero echo in footer is a contract error', () => { + const bad = FIXTURE_GOOD.replace( + //, + '

O RGB do PC, no seu controle.

', + ); + const errors = landingContractErrors(bad); + assert.ok(errors.some((e) => /hero tagline/i.test(e))); +}); + +/* ── regression (shipped HTML) ── */ + +test('regression: docs/index.html satisfies landing contract', () => { + assert.doesNotThrow(() => assertLandingContract(readLanding())); +}); + +test('regression: no Código aberto / Fork pitch section', () => { + const html = readLanding(); + assert.equal(/Código aberto\. Fork à vontade\./.test(html), false); + assert.equal(/id=["']involved-title["']/.test(html), false); +}); + +/* ── e2e-structure (document order / seams) ── */ + +test('e2e: main closes then install sheet then footer — no involved between', () => { + const html = readLanding(); + const mainClose = html.lastIndexOf(''); + const install = html.indexOf('id="install"'); + const footer = html.indexOf(' 0); + assert.ok(install > mainClose, 'install sheet follows main'); + assert.ok(footer > install, 'footer follows install sheet'); + const between = html.slice(mainClose, footer); + assert.equal(/\binvolved\b/.test(between), false); +}); + +test('e2e: footer is a thin site-foot, not a second hero', () => { + const html = readLanding(); + const i = inspectLanding(html); + assert.ok(/site-foot/.test(i.footerHtml), 'footer uses site-foot class'); + assert.equal(i.footerUsesStatementDisplay, false); + assert.equal(i.footerEchoesHero, false); + assert.match(i.footerHtml, /Nightwolf RGB/); + assert.match(i.footerHtml, /\bMIT\b/); +}); + +test('e2e: Baixar still opens install popover (mast + hero)', () => { + const html = readLanding(); + const i = inspectLanding(html); + assert.ok(i.installSheetPresent); + assert.ok(i.baixarOpensInstall >= 2); +}); + +/* ── mutation (kill score) ── */ + +test('mutation: readd-involved is killed by contract', () => { + const mutant = mutateLanding(FIXTURE_GOOD, 'readd-involved'); + const errors = landingContractErrors(mutant); + assert.ok(errors.length >= 1); + assert.ok(errors.some((e) => /involved/i.test(e))); +}); + +test('mutation: hero-echo-footer is killed by contract', () => { + const mutant = mutateLanding(FIXTURE_GOOD, 'hero-echo-footer'); + const errors = landingContractErrors(mutant); + assert.ok(errors.some((e) => /hero tagline|statement display/i.test(e))); +}); + +test('mutation: drop-mit is killed by contract', () => { + const mutant = mutateLanding(FIXTURE_GOOD, 'drop-mit'); + const errors = landingContractErrors(mutant); + assert.ok(errors.some((e) => /MIT/i.test(e))); +}); + +test('mutation: windows-lock is killed by contract', () => { + const mutant = mutateLanding(FIXTURE_GOOD, 'windows-lock'); + const errors = landingContractErrors(mutant); + assert.ok(errors.some((e) => /Windows/i.test(e))); +}); + +test('mutation: shipping HTML mutants stay red against live file', () => { + const live = readLanding(); + // If live is already good, mutating it must still fail. + for (const name of ['readd-involved', 'hero-echo-footer', 'drop-mit', 'windows-lock']) { + const errors = landingContractErrors(mutateLanding(live, name)); + assert.ok(errors.length > 0, `live mutant ${name} must fail`); + } +}); diff --git a/scripts/pack-release.cjs b/scripts/pack-release.cjs index e62d3ba..c51a403 100644 --- a/scripts/pack-release.cjs +++ b/scripts/pack-release.cjs @@ -21,6 +21,7 @@ const SKIP_DIR = new Set([ '.gauntlet', '.cache', '.harness', + '.superpowers', ]); const SKIP_FILE = new Set(['.env', '.env.local']); diff --git a/scripts/secrets-scan.cjs b/scripts/secrets-scan.cjs index 2d83fc1..8ad2694 100644 --- a/scripts/secrets-scan.cjs +++ b/scripts/secrets-scan.cjs @@ -16,6 +16,8 @@ const SKIP_DIR = new Set([ 'dist', 'coverage', '.gauntlet', + '.harness', + '.superpowers', '.cache', ]);