← 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
M .gitignoreM electron-main.jsA screenrecord/run.jsA selftest.jsM server.jsM start.command
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 →