[object Object]

← back to Dot Palette

dot-palette: side-effect-free test seam + selftest + safe-by-default screenrecord (TK-11877)

bc3244f1ce960a1225a44c1b8c0486fbc457b17d · 2026-09-17 07:32:18 -0700 · Steve Abrams

Hardening pass (Cody FIX-FIRST gated). server.js gains 4 test seams
(DOTPALETTE_ENGINE/_BIN/_TTY_OVERRIDE/_PORT_FILE) each with a || fallback, so
the full HTTP->engine path can be exercised on an ephemeral port with a mock
engine + fake tty, never touching a real iTerm2 tab or the live :9791 palette.

Production isolation is CODE-enforced, not assumed: start.command unsets the
seams and electron-main.js strips them from the spawned server.js env, so a
stray seam in the shell can never leak in to mock the engine or mis-target a tty.

selftest.js: 7/7 incl 2 NEGATIVE cases (reddens on injected engine failure +
unresolvable tty) per CLAUDE.md TK-11431 amд3. screenrecord/run.js: was hitting
the LIVE palette (repainting the focused tab, 5x); now boots its own isolated
instance by default, live only on explicit --live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wvstRQCpemptApArrjQRg

Files touched

Diff

commit bc3244f1ce960a1225a44c1b8c0486fbc457b17d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 17 07:32:18 2026 -0700

    dot-palette: side-effect-free test seam + selftest + safe-by-default screenrecord (TK-11877)
    
    Hardening pass (Cody FIX-FIRST gated). server.js gains 4 test seams
    (DOTPALETTE_ENGINE/_BIN/_TTY_OVERRIDE/_PORT_FILE) each with a || fallback, so
    the full HTTP->engine path can be exercised on an ephemeral port with a mock
    engine + fake tty, never touching a real iTerm2 tab or the live :9791 palette.
    
    Production isolation is CODE-enforced, not assumed: start.command unsets the
    seams and electron-main.js strips them from the spawned server.js env, so a
    stray seam in the shell can never leak in to mock the engine or mis-target a tty.
    
    selftest.js: 7/7 incl 2 NEGATIVE cases (reddens on injected engine failure +
    unresolvable tty) per CLAUDE.md TK-11431 amд3. screenrecord/run.js: was hitting
    the LIVE palette (repainting the focused tab, 5x); now boots its own isolated
    instance by default, live only on explicit --live.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017wvstRQCpemptApArrjQRg
---
 .gitignore          |   2 +
 electron-main.js    |   7 ++-
 screenrecord/run.js | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 selftest.js         | 118 ++++++++++++++++++++++++++++++++++++
 server.js           |  16 ++++-
 start.command       |   5 ++
 6 files changed, 312 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index c4be4df..b208a71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ build/
 .next/
 .port
 .pos
+screenrecord/rec/
+screenrecord/debug-log.jsonl
diff --git a/electron-main.js b/electron-main.js
index 20e5685..fd6ade9 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -26,8 +26,13 @@ function alive(port) {
 }
 // Spawn our own server via Electron-as-node (no separate node install needed).
 async function ensureServer() {
+  // Code-enforce production isolation at the real chokepoint every launch funnels
+  // through: strip server.js's test seams from the child env so they can NEVER leak
+  // in from an inherited shell env (belt-and-suspenders with start.command's unset).
+  const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' };
+  for (const k of ['DOTPALETTE_ENGINE', 'DOTPALETTE_ENGINE_BIN', 'DOTPALETTE_TTY_OVERRIDE', 'DOTPALETTE_PORT_FILE']) delete env[k];
   serverProc = spawn(process.execPath, [path.join(DIR, 'server.js')], {
-    stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } });
+    stdio: 'ignore', env });
   for (let i = 0; i < 40; i++) {
     await new Promise(r => setTimeout(r, 150));
     const p = portFromFile();
diff --git a/screenrecord/run.js b/screenrecord/run.js
new file mode 100644
index 0000000..e6cbeb0
--- /dev/null
+++ b/screenrecord/run.js
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+// screenrecord agent for dot-palette (TK-11877, empirical test gate cycle 1)
+// Records 5 passes over http://127.0.0.1:9791/, each a distinct click-order
+// combination, appending every action + console/pageerror to debug-log.jsonl.
+'use strict';
+const { chromium } = require('playwright');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const ROOT = __dirname;
+// SAFE BY DEFAULT: this harness clicks every colour chip, and against the LIVE
+// palette each click repaints whatever iTerm2 tab is focused (via the real engine).
+// So by default we boot our OWN isolated dot-palette server (PORT=0) with the test
+// seams — a mock engine + a fake tty — so no real tab is ever touched. Pass --live
+// ONLY to drive the real :9791 (it WILL repaint your focused tab). See selftest.js
+// for the deterministic (no-browser) gate.
+const LIVE = process.argv.includes('--live');
+const LOG = path.join(ROOT, 'debug-log.jsonl');
+let URL = 'http://127.0.0.1:9791/';   // set to the isolated instance below unless --live
+let _srv = null, _tmp = null;
+
+async function bootIsolated() {
+  _tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dotpalette-rec-'));
+  const mock = path.join(_tmp, 'mock-engine.py');
+  fs.writeFileSync(mock, 'import sys\nsys.stdout.write(" ".join(sys.argv[1:]))\n');
+  const portFile = path.join(_tmp, 'port');
+  _srv = spawn(process.execPath, [path.join(ROOT, '..', 'server.js')], {
+    stdio: 'ignore',
+    env: { ...process.env, PORT: '0', DOTPALETTE_ENGINE: mock, DOTPALETTE_ENGINE_BIN: 'python3',
+      DOTPALETTE_TTY_OVERRIDE: '/dev/ttys123', DOTPALETTE_PORT_FILE: portFile },
+  });
+  for (let i = 0; i < 60; i++) {
+    await new Promise(r => setTimeout(r, 100));
+    try { const p = parseInt(fs.readFileSync(portFile, 'utf8'), 10); if (p) { URL = `http://127.0.0.1:${p}/`; return; } } catch {}
+  }
+  throw new Error('isolated dot-palette server never came up');
+}
+function teardownIsolated() {
+  try { if (_srv) _srv.kill(); } catch {}
+  try { if (_tmp) fs.rmSync(_tmp, { recursive: true, force: true }); } catch {}
+}
+
+const prior = fs.existsSync(LOG)
+  ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
+  : [];
+const priorErrors = prior.filter(r => r.errors && r.errors.length);
+console.log(`[log] read ${prior.length} prior actions, ${priorErrors.length} with errors`);
+
+function append(o) { fs.appendFileSync(LOG, JSON.stringify(o) + '\n'); }
+
+// The full interactive surface, DOM order, with a stable selector + label.
+const BASE_ELS = [
+  { sel: '#grip', label: 'grip (drag handle)', kind: 'drag' },
+  { sel: '.chip >> nth=0', label: 'chip green (WORKING)', kind: 'chip', color: 'green' },
+  { sel: '.chip >> nth=1', label: 'chip yellow (DIRECTION?)', kind: 'chip', color: 'yellow' },
+  { sel: '.chip >> nth=2', label: 'chip orange (PASTE waiting)', kind: 'chip', color: 'orange' },
+  { sel: '.chip >> nth=3', label: 'chip purple (GATED)', kind: 'chip', color: 'purple' },
+  { sel: '.chip >> nth=4', label: 'chip lightblue (NEEDS STEVE)', kind: 'chip', color: 'lightblue' },
+  { sel: '.chip >> nth=5', label: 'chip pink (PARKED)', kind: 'chip', color: 'pink' },
+  { sel: '#x', label: 'x (close palette)', kind: 'close' },
+];
+
+function orderFor(run, els) {
+  if (run === 0) return els; // DOM order
+  if (run === 1) return [...els].reverse(); // reverse order
+  if (run === 2) {
+    // "chips-first" combination (no <input type=range> exists in this app;
+    // substitute: exercise all colour chips before the drag/close controls)
+    return [...els].sort((a, b) => (a.kind === 'chip' ? 0 : 1) - (b.kind === 'chip' ? 0 : 1));
+  }
+  if (run === 3) {
+    // seeded shuffle
+    const s = [...els];
+    for (let i = s.length - 1; i > 0; i--) {
+      const j = (i * 7 + run * 13) % (i + 1);
+      [s[i], s[j]] = [s[j], s[i]];
+    }
+    return s;
+  }
+  if (run === 4) {
+    // errored-first: re-hit anything that produced console/page errors in runs 0-3 first
+    const badSels = new Set(priorErrors.map(e => e.selector));
+    return [...els].sort((a, b) => (badSels.has(b.sel) ? 1 : 0) - (badSels.has(a.sel) ? 1 : 0));
+  }
+  return els;
+}
+
+async function main() {
+  for (let run = 0; run < 5; run++) {
+    console.log(`\n=== RUN ${run} ===`);
+    const browser = await chromium.launch();
+    const dir = path.join(ROOT, 'rec', `run${run}`);
+    fs.mkdirSync(dir, { recursive: true });
+    const ctx = await browser.newContext({
+      viewport: { width: 500, height: 300 },
+      recordVideo: { dir, size: { width: 500, height: 300 } },
+    });
+    const page = await ctx.newPage();
+    let pending = [];
+    page.on('console', m => { if (m.type() === 'error') pending.push('console.error: ' + m.text()); });
+    page.on('pageerror', e => pending.push('pageerror: ' + String(e)));
+    await page.goto(URL, { waitUntil: 'domcontentloaded' });
+    await page.waitForTimeout(300);
+
+    const order = orderFor(run, BASE_ELS);
+    console.log('order:', order.map(e => e.label).join(' -> '));
+
+    for (const el of order) {
+      const errsBefore = pending.splice(0); // errors accumulated just from page load / prior settle, flush stale
+      let ok = true, effect = '', errors = [];
+      try {
+        const handle = page.locator(el.sel).first();
+        await handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
+        const beforeClass = await handle.getAttribute('class').catch(() => null);
+        const beforeTitle = await handle.getAttribute('title').catch(() => null);
+
+        if (el.kind === 'drag') {
+          const box = await handle.boundingBox();
+          if (box) {
+            await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+            await page.mouse.down();
+            await page.mouse.move(box.x + box.width / 2 + 15, box.y + box.height / 2 + 8, { steps: 5 });
+            await page.mouse.up();
+            effect = 'dragged +15,+8 via pointer events';
+          } else {
+            ok = false; effect = 'no bounding box (not visible)';
+          }
+        } else {
+          await handle.click({ timeout: 3000 });
+          await page.waitForTimeout(250);
+          const afterClass = await handle.getAttribute('class').catch(() => null);
+          const afterTitle = await handle.getAttribute('title').catch(() => null);
+          effect = `class: "${beforeClass}" -> "${afterClass}"; title: "${beforeTitle}" -> "${afterTitle}"`;
+          if (el.kind === 'chip' && beforeClass === afterClass) {
+            // class resets after 650ms so this is expected if we polled late; not auto-fail
+          }
+        }
+        await page.waitForTimeout(400); // let async fetch/class settle
+      } catch (e) {
+        ok = false;
+        effect = 'EXCEPTION: ' + String(e).slice(0, 300);
+      }
+      errors = pending.splice(0);
+      const rec = {
+        run, ts: new Date().toISOString(), selector: el.sel, label: el.label,
+        action: el.kind === 'drag' ? 'drag' : 'click', ok, effect, errors,
+      };
+      append(rec);
+      console.log(`  [${ok ? 'ok' : 'FAIL'}] ${el.label} :: ${effect}${errors.length ? ' :: ERRORS=' + JSON.stringify(errors) : ''}`);
+    }
+
+    await ctx.close();
+    await browser.close();
+  }
+  console.log('\nAll 5 runs complete.');
+}
+
+(async () => {
+  if (LIVE) {
+    console.warn('[screenrecord] --live: driving the REAL :9791 palette — clicks WILL repaint your focused iTerm2 tab.');
+  } else {
+    await bootIsolated();
+    console.log(`[screenrecord] isolated instance at ${URL} (mock engine, fake tty — no real tab touched)`);
+  }
+  try { await main(); } finally { teardownIsolated(); }
+})().catch(e => { console.error('FATAL', e); teardownIsolated(); process.exit(1); });
diff --git a/selftest.js b/selftest.js
new file mode 100644
index 0000000..27c51b6
--- /dev/null
+++ b/selftest.js
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+// dot-palette self-test (TK-11877). Proves the FULL request path
+// (HTTP -> setDot -> engine argv) works AND reddens on an injected engine
+// failure, WITHOUT ever touching a real iTerm2 tab or the live :9791 palette.
+//
+// It boots server.js on an ephemeral port (PORT=0) with the test seams set:
+//   DOTPALETTE_ENGINE      -> a mock engine that echoes its argv (or fails)
+//   DOTPALETTE_ENGINE_BIN  -> python3
+//   DOTPALETTE_TTY_OVERRIDE-> a deterministic fake tty (no osascript, no real tab)
+//   DOTPALETTE_PORT_FILE   -> a temp file (never clobbers the live .port)
+// None of these are set by the Electron shell or launchd, so production is untouched.
+//
+// Doctrine (CLAUDE.md TK-11431 amд3): a check ships with a NEGATIVE test proving it
+// goes RED on an injected fault, or it does not ship. Run: node selftest.js
+'use strict';
+const http = require('http');
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const DIR = __dirname;
+const SERVER = path.join(DIR, 'server.js');
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dotpalette-selftest-'));
+const GOOD_TTY = '/dev/ttys123';
+
+// Mock engine: echoes argv (proves the server passed the right command); with
+// MOCK_FAIL set it exits non-zero (proves the server surfaces engine failure).
+const MOCK = path.join(TMP, 'mock-engine.py');
+fs.writeFileSync(MOCK, [
+  'import os, sys',
+  'if os.environ.get("MOCK_FAIL"):',
+  '    sys.stderr.write("mock engine forced failure"); sys.exit(1)',
+  'sys.stdout.write(" ".join(sys.argv[1:])); sys.exit(0)',
+  '',
+].join('\n'));
+
+let passed = 0, failed = 0;
+function ok(cond, msg) { (cond ? passed++ : failed++); console.log(`  ${cond ? 'PASS' : 'FAIL'} ${msg}`); }
+
+function get(port, p) {
+  return new Promise((res, rej) => {
+    const r = http.get({ host: '127.0.0.1', port, path: p, timeout: 4000 }, x => {
+      let b = ''; x.on('data', c => (b += c)); x.on('end', () => res({ code: x.statusCode, body: b }));
+    });
+    r.on('error', rej); r.on('timeout', () => { r.destroy(); rej(new Error('timeout')); });
+  });
+}
+function post(port, p, obj) {
+  const data = Buffer.from(JSON.stringify(obj));
+  return new Promise((res, rej) => {
+    const r = http.request({ host: '127.0.0.1', port, path: p, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }, timeout: 4000 },
+      x => { let b = ''; x.on('data', c => (b += c)); x.on('end', () => res({ code: x.statusCode, body: b })); });
+    r.on('error', rej); r.on('timeout', () => { r.destroy(); rej(new Error('timeout')); });
+    r.end(data);
+  });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// Boot server.js with the given extra env; return {proc, port}. Never writes the live .port.
+async function boot(extraEnv, tag) {
+  const portFile = path.join(TMP, `port-${tag}`);
+  try { fs.rmSync(portFile, { force: true }); } catch {}
+  const proc = spawn(process.execPath, [SERVER], {
+    stdio: 'ignore',
+    env: { ...process.env, PORT: '0', DOTPALETTE_ENGINE: MOCK, DOTPALETTE_ENGINE_BIN: 'python3',
+      DOTPALETTE_TTY_OVERRIDE: GOOD_TTY, DOTPALETTE_PORT_FILE: portFile, ...extraEnv },
+  });
+  for (let i = 0; i < 60; i++) {
+    await sleep(100);
+    let port = null;
+    try { port = parseInt(fs.readFileSync(portFile, 'utf8'), 10); } catch {}
+    if (port) { try { const h = await get(port, '/health'); if (h.code === 200) return { proc, port }; } catch {} }
+  }
+  throw new Error(`server (${tag}) never came up`);
+}
+function kill(proc) { try { proc.kill(); } catch {} }
+
+(async () => {
+  console.log('dot-palette selftest — isolated, no real tabs touched');
+
+  // --- POSITIVE: real engine mock succeeds, deterministic tty ---
+  const good = await boot({}, 'good');
+  try {
+    const h = await get(good.port, '/health');
+    ok(h.code === 200 && JSON.parse(h.body).ok === true, '/health -> 200 ok');
+
+    const bad = JSON.parse((await post(good.port, '/api/setdot', { color: 'NOTACOLOR' })).body);
+    ok(bad.ok === false && /bad color/.test(bad.error || ''), 'bad colour -> ok:false (no tty touched)');
+
+    const green = JSON.parse((await post(good.port, '/api/setdot', { color: 'green' })).body);
+    ok(green.ok === true && green.tty === GOOD_TTY, 'valid colour -> ok:true on the resolved tty');
+    ok(/^set green --tty \/dev\/ttys123$/.test((green.out || '').trim()),
+      'server passes engine the correct argv (set green --tty <tty>)');
+
+    const lbl = JSON.parse((await post(good.port, '/api/setdot', { color: 'purple', label: 'TK-11877 x' })).body);
+    ok(lbl.ok === true && /set purple TK-11877 x --tty/.test((lbl.out || '')), 'label is forwarded to the engine');
+  } finally { kill(good.proc); }
+
+  // --- NEGATIVE: engine exits non-zero -> the check MUST redden ---
+  const bad = await boot({ MOCK_FAIL: '1' }, 'fail');
+  try {
+    const r = JSON.parse((await post(bad.port, '/api/setdot', { color: 'green' })).body);
+    ok(r.ok === false, 'NEGATIVE: engine failure -> ok:false (reddens on injected fault)');
+  } finally { kill(bad.proc); }
+
+  // --- NEGATIVE: no resolvable tty -> ok:false, engine never invoked ---
+  const notty = await boot({ DOTPALETTE_TTY_OVERRIDE: 'not-a-tty' }, 'notty');
+  try {
+    const r = JSON.parse((await post(notty.port, '/api/setdot', { color: 'green' })).body);
+    ok(r.ok === false && /no current iTerm2/.test(r.error || ''), 'NEGATIVE: unresolvable tty -> ok:false');
+  } finally { kill(notty.proc); }
+
+  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
+  console.log(`selftest: ${failed ? 'FAIL' : 'PASS'} (${passed}/${passed + failed})`);
+  process.exit(failed ? 1 : 0);
+})().catch(e => { console.error('selftest ERROR', e && e.stack || e); process.exit(2); });
diff --git a/server.js b/server.js
index 5d23a70..a4cadfc 100644
--- a/server.js
+++ b/server.js
@@ -11,7 +11,14 @@ const { execFile } = require('child_process');
 const fs = require('fs');
 const path = require('path');
 
-const ENGINE = `${process.env.HOME}/Projects/terminal-status/terminal_status.py`;
+// TEST SEAM (inert in production): the Electron shell + launchd NEVER set these
+// env vars, so the live palette always uses the real engine + resolves the real
+// iTerm2 tty. selftest.js sets them to run the full request path against a mock
+// engine on an ephemeral port WITHOUT repainting any real tab. Doctrine: a checkable
+// seam guarded so a scheduled/production launch can never trip it (CLAUDE.md TK-11431).
+const ENGINE = process.env.DOTPALETTE_ENGINE || `${process.env.HOME}/Projects/terminal-status/terminal_status.py`;
+const ENGINE_BIN = process.env.DOTPALETTE_ENGINE_BIN || 'python3';
+const TTY_OVERRIDE = process.env.DOTPALETTE_TTY_OVERRIDE || '';   // test-only; empty in prod
 // The six status colours the engine accepts (COLORS in terminal_status.py).
 const COLORS = ['green', 'yellow', 'orange', 'purple', 'lightblue', 'pink'];
 
@@ -27,6 +34,7 @@ function run(cmd, args, timeoutMs = 8000) {
 // NOT the OS frontmost app — so a non-activating palette click still targets the
 // right pane even though iTerm2 may not be the active application at click time.
 async function frontTty() {
+  if (TTY_OVERRIDE) return /^\/dev\/ttys\d+$/.test(TTY_OVERRIDE) ? TTY_OVERRIDE : '';
   const script = 'tell application "iTerm2" to get tty of current session of current window';
   const { stdout } = await run('osascript', ['-e', script], 5000);
   return /^\/dev\/ttys\d+$/.test(stdout) ? stdout : '';
@@ -39,7 +47,7 @@ async function setDot(color, label) {
   const args = [ENGINE, 'set', color];
   if (label) args.push(String(label).slice(0, 120));
   args.push('--tty', tty);
-  const { err, stdout, stderr } = await run('python3', args, 8000);
+  const { err, stdout, stderr } = await run(ENGINE_BIN, args, 8000);
   return { ok: !err, tty, color, out: stdout, error: err ? (stderr || String(err)).slice(0, 200) : null };
 }
 
@@ -80,7 +88,9 @@ function listen(port, tries = 20) {
   });
   server.listen(port, '127.0.0.1', () => {
     const p = server.address().port;
-    fs.writeFileSync(path.join(__dirname, '.port'), String(p));
+    // DOTPALETTE_PORT_FILE is a test seam (inert in prod) so selftest.js never
+    // clobbers the live palette's .port that the Electron shell reads.
+    fs.writeFileSync(process.env.DOTPALETTE_PORT_FILE || path.join(__dirname, '.port'), String(p));
     console.log(`dot-palette on http://127.0.0.1:${p}`);
   });
 }
diff --git a/start.command b/start.command
index 4654ff6..28e47ab 100755
--- a/start.command
+++ b/start.command
@@ -12,4 +12,9 @@ if pgrep -f "dot-palette/electron-main.js" >/dev/null 2>&1; then
   echo "dot-palette already running."
   exit 0
 fi
+# Enforce production isolation: server.js's test seams (used only by selftest.js /
+# screenrecord --isolated) are child-inherited env vars. Clearing them here makes
+# "the seams are inert in production" a CODE-enforced fact, not a convention — a stray
+# seam left in Steve's shell can never leak in to mock the engine or mis-target a tty.
+unset DOTPALETTE_ENGINE DOTPALETTE_ENGINE_BIN DOTPALETTE_TTY_OVERRIDE DOTPALETTE_PORT_FILE
 exec "$ELECTRON" . >/tmp/dot-palette.log 2>&1

← 2629bec dot-palette: always-on-top click palette to set the current  ·  back to Dot Palette  ·  dot-palette: guard IPC calls so the palette degrades gracefu 9fc1f03 →