← back to Ticket Triage Page
Hybrid execute: fire safe/reversible actions locally, copy-to-clipboard prod actions (server holds no prod exec path); bind 127.0.0.1
446e2be5b743c61b2a7ea0cce7a19597d54d73dd · 2026-07-30 20:52:21 -0700 · Steve Abrams
Files touched
Diff
commit 446e2be5b743c61b2a7ea0cce7a19597d54d73dd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 30 20:52:21 2026 -0700
Hybrid execute: fire safe/reversible actions locally, copy-to-clipboard prod actions (server holds no prod exec path); bind 127.0.0.1
---
.gitignore | 1 +
server.js | 142 ++++++++++++++++++++++++++++++++-----------------------------
2 files changed, 75 insertions(+), 68 deletions(-)
diff --git a/.gitignore b/.gitignore
index 08240ae..58680c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ tmp/
.DS_Store
dist/
build/
+.secret-*.json
diff --git a/server.js b/server.js
index 930c57a..5aca62b 100644
--- a/server.js
+++ b/server.js
@@ -1,9 +1,11 @@
#!/usr/bin/env node
-// Ticket Triage page — select + APPROVE + FIRE the 10 Steve-gated blocked tickets.
-// Zero deps (built-in http). Basic-auth admin/DW2024!. Open /healthz. Port 9759, bound 127.0.0.1 ONLY.
-// Buttons execute the runbook command verbatim. danger:'live' requires typing RUN to confirm.
-// Every fire is logged to the ticket (tk comment) with the command + exit code. Steve is the
-// human gate: nothing fires without his click (+ typed RUN on live ones). TK-13 is hands-off.
+// Ticket Triage — HYBRID: fire the safe/reversible actions, copy the prod ones.
+// Zero deps. Basic-auth admin/DW2024!. Open /healthz. Port 9759, bound 127.0.0.1 ONLY.
+//
+// SAFETY MODEL: the server's spawn() is reachable ONLY from exec.mode==='fire', whose
+// commands are local + reversible (TikTok dry-run). Prod/destructive actions are
+// mode:'copy' — the server NEVER runs them; the page copies pasteCmd to the clipboard and
+// you run it in a terminal (per-command gated). 'copy' Mark-fired only records to the ticket.
const http = require('http');
const fs = require('fs');
const { execFileSync, spawn } = require('child_process');
@@ -11,7 +13,7 @@ const path = require('path');
const os = require('os');
const PORT = process.env.PORT || 9759;
-const HOST = '127.0.0.1'; // localhost-only — these buttons can hit prod; never bind 0.0.0.0
+const HOST = '127.0.0.1';
const USER = process.env.BASIC_AUTH_USER || 'admin';
const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
const TK = path.join(os.homedir(), 'Projects/ticket-system/tk');
@@ -26,14 +28,13 @@ function last4(v) { v = String(v || ''); return v.length <= 4 ? '****' : '…' +
const BUCKET = { approval: ['#16a34a', '1-word approval'], deploy: ['#2563eb', 'deploy'], paste: ['#7c3aed', 'command'], task: ['#ea580c', 'hands-on task'], auto: ['#64748b', 'no action'] };
-// Run a shell command, capture combined output, cap runtime. Callback(code, output).
+// runCmd is only ever called with a `fire`-mode ticket's own cmd (local + reversible).
function runCmd(cmd, cb) {
const child = spawn('bash', ['-lc', cmd], { env: process.env });
let out = '', done = false;
- const finish = (code) => { if (done) return; done = true; cb(code, out.slice(0, 60000)); };
- const to = setTimeout(() => { out += '\n[triage] TIMEOUT after 300s — killed.\n'; child.kill('SIGKILL'); finish(124); }, 300000);
- child.stdout.on('data', d => out += d);
- child.stderr.on('data', d => out += d);
+ const finish = code => { if (done) return; done = true; cb(code, out.slice(0, 60000)); };
+ const to = setTimeout(() => { out += '\n[triage] TIMEOUT 120s — killed.\n'; child.kill('SIGKILL'); finish(124); }, 120000);
+ child.stdout.on('data', d => out += d); child.stderr.on('data', d => out += d);
child.on('close', code => { clearTimeout(to); finish(code); });
child.on('error', e => { clearTimeout(to); out += '\n[triage] spawn error: ' + e.message + '\n'; finish(1); });
}
@@ -43,89 +44,88 @@ function page() {
const cards = TICKETS.map(t => {
const [color, blabel] = BUCKET[t.bucket];
const still = blocked.has(t.ref.match(/TK-[0-9]+/)[0]);
- const e = t.exec, live = e.danger === 'live';
- let btn;
- if (e.mode === 'handsoff') btn = `<span class="handsoff">⬜ hands-off</span>`;
- else if (e.mode === 'input') btn = `<button type="button" class="act input" data-ref="${esc(t.ref)}">${esc(e.btn)}</button>`;
- else btn = `<button type="button" class="act ${live ? 'live' : 'safe'}" data-ref="${esc(t.ref)}">${live ? '🔴 ' : ''}${esc(e.btn)}</button>`;
+ const e = t.exec;
+ let ctrl;
+ if (e.mode === 'handsoff') ctrl = `<span class="handsoff">⬜ hands-off</span>`;
+ else if (e.mode === 'copy') ctrl = `<button type="button" class="btn copy" data-ref="${esc(t.ref)}">📋 Copy: ${esc(e.btn)}</button><button type="button" class="btn mark" data-ref="${esc(t.ref)}">✓ Mark fired</button>`;
+ else if (e.mode === 'input') ctrl = `<button type="button" class="btn input act" data-ref="${esc(t.ref)}">${esc(e.btn)}</button>`;
+ else ctrl = `<button type="button" class="btn ${e.mode === 'fire' ? 'fire' : 'dec'} act" data-ref="${esc(t.ref)}">${e.mode === 'fire' ? '▶ ' : ''}${esc(e.btn)}</button>`;
return `<div class="card${still ? '' : ' cleared'}" data-bucket="${t.bucket}" id="card-${esc(t.ref)}">
<div class="top"><span class="ref">${esc(t.ref)}</span>
<span class="badge" style="background:${color}">${blabel}</span>
<span class="rec">rec: <b>${esc(t.recommend)}</b></span>
+ ${e.danger === 'live' ? '<span class="livetag">LIVE — paste in terminal</span>' : ''}
${still ? '' : '<span class="done-pill">✓ cleared</span>'}</div>
<div class="title">${esc(t.title)}</div>
<div class="action">${esc(t.action)}</div>
- <div class="foot">${btn}
- ${t.memo ? `<button type="button" class="memo" data-file="${esc(t.memo)}">📄 runbook</button>` : ''}</div>
+ ${e.pasteCmd ? `<pre class="cmd">${esc(e.pasteCmd)}</pre>` : ''}
+ <div class="foot">${ctrl}${t.memo ? `<button type="button" class="btn ghost memo" data-file="${esc(t.memo)}">📄 runbook</button>` : ''}</div>
<pre class="output" id="out-${esc(t.ref)}" hidden></pre>
</div>`;
}).join('\n');
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<title>Blocked-Ticket Triage · approve & fire</title><style>
+<title>Blocked-Ticket Triage · fire safe · copy prod</title><style>
:root{--bg:#0f172a;--card:#1e293b;--ink:#e2e8f0;--mut:#94a3b8;--line:#334155}
*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,system-ui,sans-serif;background:var(--bg);color:var(--ink)}
header{position:sticky;top:0;background:#0b1220;border-bottom:1px solid var(--line);padding:14px 20px;z-index:5}
h1{margin:0;font-size:18px}.sub{color:var(--mut);font-size:13px;margin-top:2px}
-.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px}
-button,select,input{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px}
-button{cursor:pointer}.act{font-weight:600}.act.safe{background:#16a34a;border-color:#16a34a}
-.act.live{background:#dc2626;border-color:#dc2626}.act.input{background:#7c3aed;border-color:#7c3aed}
-.act:disabled{opacity:.5;cursor:progress}
+.bar{display:flex;gap:10px;margin-top:12px}
+select,input,button{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px}
+.btn{cursor:pointer;font-weight:600}.act.fire,.btn.fire{background:#16a34a;border-color:#16a34a}
+.btn.dec{background:#0ea5e9;border-color:#0ea5e9}.btn.input{background:#7c3aed;border-color:#7c3aed}
+.btn.copy{background:#334155}.btn.mark{background:#1e293b}.btn.ghost{background:transparent}
+.btn:disabled{opacity:.5;cursor:progress}.btn.copied{background:#16a34a;border-color:#16a34a}
main{padding:18px 20px;max-width:900px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
-.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
-.card.cleared{opacity:.5}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}.card.cleared{opacity:.5}
.top{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
.ref{font-weight:700;font-family:ui-monospace,monospace}.badge{color:#fff;font-size:11px;padding:2px 8px;border-radius:20px}
.rec{color:var(--mut);font-size:12px}.rec b{color:var(--ink)}.done-pill{color:#22c55e;font-size:12px}
+.livetag{color:#fca5a5;font-size:11px;border:1px solid #7f1d1d;padding:1px 7px;border-radius:20px}
.title{font-weight:600;margin:2px 0}.action{color:var(--mut);font-size:13.5px}
-.foot{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}.memo{padding:6px 10px;font-size:12.5px}
+.cmd{margin:8px 0 0;padding:10px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:11.5px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow:auto}
+.foot{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}
.handsoff{color:var(--mut);font-size:13px;padding:6px 0}
.output{margin:10px 0 0;padding:12px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap;max-height:340px;overflow:auto}
.output.ok{border-color:#16a34a}.output.err{border-color:#dc2626}
#modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;padding:20px;z-index:10}
#modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:760px;width:100%;max-height:88vh;display:flex;flex-direction:column}
-#modal .h{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}
-#modal .c{padding:16px;overflow:auto}
-#modal pre{margin:0 0 12px;padding:12px;background:#0b1220;border-radius:8px;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
-.warn{color:#fca5a5;font-size:13px;margin:8px 0}.fld{display:block;margin:8px 0}.fld label{display:block;font-size:12px;color:var(--mut);margin-bottom:3px}.fld input{width:100%}
-.confirm-row{display:flex;gap:8px;align-items:center;margin-top:10px}
+#modal .h{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
+#modal .c{padding:16px;overflow:auto}#modal pre{margin:0 0 12px;padding:12px;background:#0b1220;border-radius:8px;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
+.fld{display:block;margin:8px 0}.fld label{display:block;font-size:12px;color:var(--mut);margin-bottom:3px}.fld input{width:100%}
</style></head><body>
-<header><h1>Blocked-Ticket Triage — approve & fire</h1>
-<div class="sub">Each button executes its ticket's runbook command. 🔴 live actions need you to type <b>RUN</b>. Every fire is logged to the ticket. You are the gate — nothing runs without your click.</div>
+<header><h1>Blocked-Ticket Triage — fire safe · copy prod</h1>
+<div class="sub">Green ▶ / blue buttons fire locally & are reversible. <span style="color:#fca5a5">LIVE</span> prod actions show the exact command to <b>copy & paste in your terminal</b> (per-command gated), then <b>Mark fired</b>. Every action logs to its ticket.</div>
<div class="bar"><select id="filter"><option value="">All buckets</option><option value="approval">approvals</option><option value="deploy">deploys</option><option value="paste">commands</option><option value="task">tasks</option><option value="auto">no action</option></select></div></header>
<main>${cards}</main>
-<div id="modal"><div class="box"><div class="h"><b id="mtitle"></b><button type="button" id="mclose">close</button></div><div class="c" id="mbody"></div></div></div>
+<div id="modal"><div class="box"><div class="h"><b id="mtitle"></b><button type="button" class="btn ghost" id="mclose">close</button></div><div class="c" id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
const T=${JSON.stringify(TICKETS.map(t => ({ ref: t.ref, exec: t.exec })))};
+function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
$('#filter').onchange=e=>{const v=e.target.value;$$('.card').forEach(c=>c.style.display=(!v||c.dataset.bucket===v)?'':'none')};
-$$('.memo').forEach(b=>b.onclick=async()=>{openModal(b.dataset.file.split('/').pop(),'<pre>loading…</pre>');const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').innerHTML='<pre></pre>';$('#mbody pre').textContent=await r.text()});
function openModal(title,html){$('#mtitle').textContent=title;$('#mbody').innerHTML=html;$('#modal').style.display='flex'}
$('#mclose').onclick=()=>$('#modal').style.display='none';$('#modal').onclick=e=>{if(e.target.id==='modal')$('#modal').style.display='none'};
+$$('.memo').forEach(b=>b.onclick=async()=>{openModal(b.dataset.file.split('/').pop(),'<pre>loading…</pre>');const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').innerHTML='<pre></pre>';$('#mbody pre').textContent=await r.text()});
+
+// COPY prod command to clipboard
+$$('.btn.copy').forEach(b=>b.onclick=async()=>{const t=T.find(x=>x.ref===b.dataset.ref);try{await navigator.clipboard.writeText(t.exec.pasteCmd);b.classList.add('copied');b.textContent='✓ copied — paste in terminal';setTimeout(()=>{b.classList.remove('copied');b.textContent='📋 Copy: '+t.exec.btn},2500);}catch{const p=document.querySelector('#card-'+b.dataset.ref+' .cmd');if(p){const r=document.createRange();r.selectNode(p);getSelection().removeAllRanges();getSelection().addRange(r);}}});
+
+async function post(ref,body){const r=await fetch('/api/exec',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ref,...body})});return r.json()}
+function showOut(ref,j){const out=$('#out-'+ref);out.hidden=false;out.className='output '+(j.ok?'ok':'err');out.textContent=(j.ok?'✓ ':'✗ ')+(j.msg||'')+(j.output?('\\n\\n'+j.output):'');out.scrollIntoView({behavior:'smooth',block:'center'})}
+
+// MARK fired (copy-mode): record that Steve ran the prod command in a terminal
+$$('.btn.mark').forEach(b=>b.onclick=async()=>{b.disabled=true;const j=await post(b.dataset.ref,{markFired:true});showOut(b.dataset.ref,j);b.disabled=false;b.textContent=j.ok?'✓ marked':'retry'});
-$$('.act').forEach(b=>b.onclick=()=>{const ref=b.dataset.ref;const t=T.find(x=>x.ref===ref);const e=t.exec;
- const live=e.danger==='live';
+// ACT (fire / decision / input)
+$$('.act').forEach(b=>b.onclick=()=>{const t=T.find(x=>x.ref===b.dataset.ref),e=t.exec;
let body='<div>'+esc(e.detail||'')+'</div>';
- if(e.mode==='fire'){body+='<div style="margin-top:10px;color:#94a3b8;font-size:12px">Command that will run:</div><pre>'+esc(e.cmd)+'</pre>';}
+ if(e.mode==='fire'){body+='<div style="margin-top:10px;color:#94a3b8;font-size:12px">Runs locally:</div><pre>'+esc(e.cmd)+'</pre>';}
if(e.mode==='input'){body+=e.fields.map(f=>'<div class="fld"><label>'+esc(f.label)+'</label><input data-f="'+f.name+'" type="'+(f.type||'text')+'" autocomplete="off"></div>').join('');}
- if(live){body+='<div class="warn">⚠ LIVE action. Type <b>RUN</b> to confirm.</div>';}
- body+='<div class="confirm-row">'+(live?'<input id="cw" placeholder="type RUN" style="width:130px" autocomplete="off">':'')+'<button id="go" class="act '+(live?'live':(e.mode==='input'?'input':'safe'))+'">'+(live?'🔴 ':'')+'Confirm & fire</button></div>';
- openModal(ref+' · '+esc(e.btn),body);
- $('#go').onclick=async()=>{
- const payload={ref};
- if(live){const cw=$('#cw').value.trim();if(cw!=='RUN'){$('#cw').style.borderColor='#dc2626';return;}payload.confirm='RUN';}
- if(e.mode==='input'){payload.inputs={};$$('#mbody input[data-f]').forEach(i=>payload.inputs[i.dataset.f]=i.value);}
- $('#go').disabled=true;$('#go').textContent='running…';
- const r=await fetch('/api/exec',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});
- const j=await r.json();$('#modal').style.display='none';
- const out=$('#out-'+ref);out.hidden=false;out.className='output '+(j.ok?'ok':'err');
- out.textContent=(j.ok?'✓ ':'✗ ')+(j.msg||'')+(j.output?('\\n\\n'+j.output):'');
- out.scrollIntoView({behavior:'smooth',block:'center'});
- b.textContent=j.ok?'✓ fired':'✗ failed — retry';b.disabled=false;
- };
-});
-function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
+ body+='<div style="margin-top:12px"><button id="go" class="btn '+(e.mode==='fire'?'fire':e.mode==='input'?'input':'dec')+'">'+(e.mode==='fire'?'▶ Run now':e.mode==='input'?'Save + prep':'Confirm')+'</button></div>';
+ openModal(b.dataset.ref+' · '+esc(e.btn),body);
+ $('#go').onclick=async()=>{const payload={};if(e.mode==='input'){payload.inputs={};$$('#mbody input[data-f]').forEach(i=>payload.inputs[i.dataset.f]=i.value);}
+ $('#go').disabled=true;$('#go').textContent='working…';const j=await post(b.dataset.ref,payload);$('#modal').style.display='none';showOut(b.dataset.ref,j);b.textContent=j.ok?'✓ done':'retry'}});
</script></body></html>`;
}
@@ -146,36 +146,42 @@ const server = http.createServer((req, res) => {
if (url.pathname === '/api/exec' && req.method === 'POST') {
let b = ''; req.on('data', c => b += c); req.on('end', () => {
- let ref, confirm, inputs;
- try { ({ ref, confirm, inputs } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"msg":"bad json"}'); }
+ let ref, inputs, markFired;
+ try { ({ ref, inputs, markFired } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"msg":"bad json"}'); }
const t = byRef(ref); if (!t) { res.writeHead(404); return res.end('{"ok":false,"msg":"unknown ticket"}'); }
const e = t.exec;
const reply = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
- if (e.mode === 'handsoff') return reply({ ok: false, msg: 'Hands-off — owned by the armed idle-watch; not fireable here.' });
+ if (e.mode === 'handsoff') return reply({ ok: false, msg: 'Hands-off — owned by the armed idle-watch.' });
+
+ // copy-mode: server NEVER runs the prod command. Mark-fired only records intent.
+ if (e.mode === 'copy') {
+ if (!markFired) return reply({ ok: false, msg: 'Use Copy to grab the command, run it in a terminal, then Mark fired.' });
+ tk(['comment', ref, `Steve ran the gated command in a terminal (marked fired via triage page): \`${e.pasteCmd}\``]);
+ tk(['status', ref, 'doing']);
+ return reply({ ok: true, msg: 'Recorded: you ran it in the terminal → ticket set to doing.' });
+ }
if (e.mode === 'decision') {
tk(['comment', ref, `Steve APPROVED via triage page: ${e.detail}`]);
tk(['status', ref, 'doing']);
- return reply({ ok: true, msg: 'Decision recorded (APPROVE) + ticket → doing.' });
+ return reply({ ok: true, msg: 'Decision recorded (APPROVE) → doing.' });
}
if (e.mode === 'input') {
const digest = (e.fields || []).map(f => `${f.name}=${last4(inputs && inputs[f.name])}`).join(', ');
- // Persist the provided secrets to a gitignored local file the terminal step reads — never echoed back.
try { fs.writeFileSync(path.join(__dirname, `.secret-${ref}.json`), JSON.stringify(inputs || {}), { mode: 0o600 }); } catch {}
- tk(['comment', ref, `Steve provided inputs via triage page (${digest}); saved to gitignored .secret-${ref}.json. nginx auth-drop is the remaining terminal step (prod SSH, irreversible-ish).`]);
+ tk(['comment', ref, `Steve provided inputs via triage page (${digest}); saved to gitignored .secret-${ref}.json. nginx auth-drop remains a terminal step.`]);
tk(['status', ref, 'doing']);
- return reply({ ok: true, msg: `Recorded ${digest}. Saved for the terminal step (nginx drop stays a manual prod SSH).` });
+ return reply({ ok: true, msg: `Recorded ${digest}. Saved for the terminal step.` });
}
+ // fire-mode: local + reversible only. This is the ONLY path to spawn().
if (e.mode === 'fire') {
- if (e.danger === 'live' && confirm !== 'RUN') return reply({ ok: false, msg: 'Live action needs typed RUN confirmation.' });
- tk(['status', ref, 'doing']);
- tk(['log', ref, `FIRING via triage page: ${e.cmd}`]);
+ tk(['status', ref, 'doing']); tk(['log', ref, `FIRING (local) via triage page: ${e.cmd}`]);
runCmd(e.cmd, (code, output) => {
const ok = code === 0;
- tk(['comment', ref, `Triage-page fire ${ok ? 'SUCCESS' : 'exit=' + code}: \`${e.cmd}\``]);
+ tk(['comment', ref, `Triage-page local fire ${ok ? 'SUCCESS' : 'exit=' + code}: \`${e.cmd}\``]);
reply({ ok, msg: `exit ${code}` + (ok ? '' : ' — see output'), output });
});
return;
@@ -186,4 +192,4 @@ const server = http.createServer((req, res) => {
}
res.writeHead(404); res.end('not found');
});
-server.listen(PORT, HOST, () => console.log(`ticket-triage on http://${HOST}:${PORT} (admin/DW2024!, /healthz open, buttons FIRE)`));
+server.listen(PORT, HOST, () => console.log(`ticket-triage on http://${HOST}:${PORT} (fire-safe/copy-prod, /healthz open)`));
← 2dc79d9 auto-save: 2026-07-30T20:51:09 (2 files) — server.js tickets
·
back to Ticket Triage Page
·
(newest)