Files
Rohit MahtoandCommandCodeBot e43313b9f4 feat: password-protect file manager and terminal (0.6.1)
- New password_auth module: random token generated on first run, encrypted
  at rest under ~/.champdev (Fernet, cryptography dep), verify + 12h signed
  session cookie. CHAMPDEV_AUTH_PASSWORD env overrides the generated token
  and is what gets sent to telemetry.
- File Manager: write actions (upload/delete/rename/move/copy/mkdir) hidden
  behind an in-UI Unlock dialog and enforced server-side (401). While locked,
  browsing is clamped to the ComfyUI root instead of 403ing, with a lock
  banner. Unlock reveals write actions and allows browsing above the root.
- Terminal: websocket refuses to spawn a shell without auth; lock overlay
  with Unlock dialog, reconnects on success.
- Telemetry: token included in the ingest payload (server column exists).
- Frontend: shared champdev_auth.js in-UI password modal (window.prompt was
  silently blocked in embedded contexts).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-23 19:51:42 +05:30

254 lines
7.3 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { unlockWithDialog } from "./champdev_auth.js";
const VENDOR = new URL("./vendor/", import.meta.url).href;
function loadXterm() {
if (window.__champTermAssets) return window.__champTermAssets;
window.__champTermAssets = new Promise((resolve, reject) => {
if (!document.getElementById("champ-term-css")) {
const link = document.createElement("link");
link.id = "champ-term-css";
link.rel = "stylesheet";
link.href = VENDOR + "xterm.css";
document.head.append(link);
}
const s1 = document.createElement("script");
s1.src = VENDOR + "xterm.js";
s1.onerror = () => reject(new Error("failed to load xterm.js"));
s1.onload = () => {
const s2 = document.createElement("script");
s2.src = VENDOR + "addon-fit.js";
s2.onerror = () => reject(new Error("failed to load addon-fit.js"));
s2.onload = () => resolve();
document.head.append(s2);
};
document.head.append(s1);
});
return window.__champTermAssets;
}
function wsUrl(params) {
const u = new URL(api.apiURL("/champdev/terminal/ws"), window.location.origin);
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
return u.href;
}
function widgetValue(node, name) {
return node.widgets?.find((w) => w.name === name)?.value ?? "";
}
async function authStatus() {
try {
const r = await api.fetchApi("/champdev/auth/status");
const res = await r.json();
return !!res.authed;
} catch (e) {
return false;
}
}
async function setupTerminal(node) {
const root = document.createElement("div");
root.style.cssText =
"display:flex;flex-direction:column;width:100%;height:100%;background:#1e1e1e;border-radius:6px;overflow:hidden";
const bar = document.createElement("div");
bar.style.cssText =
"display:flex;gap:6px;padding:4px 6px;border-bottom:1px solid #333;flex:0 0 auto";
const restart = document.createElement("button");
restart.textContent = "⟳ Restart";
restart.style.cssText =
"background:#3a3a3a;color:#ddd;border:1px solid #555;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:12px";
const status = document.createElement("span");
status.style.cssText = "color:#888;font-size:12px;align-self:center";
bar.append(restart, status);
const host = document.createElement("div");
host.style.cssText = "flex:1 1 auto;min-height:0;padding:4px";
root.append(bar, host);
// Lock overlay: shown until the password unlocks the session. The backend
// also refuses to spawn a shell without auth.
const lockOverlay = document.createElement("div");
lockOverlay.style.cssText =
"position:absolute;inset:0;display:flex;flex-direction:column;gap:10px;align-items:center;" +
"justify-content:center;background:rgba(30,30,30,.92);z-index:10;border-radius:6px;color:#ddd";
const lockMsg = document.createElement("div");
lockMsg.textContent = "🔒 Unlock to use terminal";
const lockBtn = document.createElement("button");
lockBtn.textContent = "Unlock";
lockBtn.style.cssText =
"background:#3a3a3a;color:#ddd;border:1px solid #555;border-radius:4px;padding:4px 14px;cursor:pointer;font-size:13px";
lockOverlay.append(lockMsg, lockBtn);
root.style.position = "relative";
root.append(lockOverlay);
async function ensureUnlocked() {
if (await authStatus()) return true;
status.textContent = "locked";
lockOverlay.style.display = "flex";
return false;
}
lockBtn.addEventListener("click", async () => {
lockBtn.disabled = true;
lockBtn.textContent = "…";
const ok = await unlockWithDialog("Password (from the telemetry dashboard):");
lockBtn.disabled = false;
lockBtn.textContent = "Unlock";
if (ok) {
lockOverlay.style.display = "none";
status.textContent = "connecting…";
connect();
}
});
node.addDOMWidget("champ_term", "div", root, { serialize: false, hideOnZoom: false });
node.size = [620, 400];
await loadXterm();
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: "monospace",
theme: { background: "#1e1e1e" },
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(host);
let ws = null;
const sendResize = () => {
try {
fit.fit();
} catch (e) {
/* host not measurable yet */
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
const connect = () => {
try {
fit.fit();
} catch (e) {
/* ignore */
}
status.textContent = "connecting…";
ws = new WebSocket(
wsUrl({
cols: term.cols || 80,
rows: term.rows || 24,
shell: widgetValue(node, "shell"),
cwd: widgetValue(node, "start_dir"),
})
);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
status.textContent = "connected";
term.focus();
sendResize();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
const m = JSON.parse(ev.data);
if (m.type === "exit") {
term.write(`\r\n\x1b[33m[process exited: ${m.code}${m.error ? " — " + m.error : ""}]\x1b[0m\r\n`);
status.textContent = "ended";
}
} catch (e) {
/* ignore */
}
} else {
term.write(new Uint8Array(ev.data));
}
};
ws.onerror = () => {
term.write("\r\n\x1b[31m[connection error]\x1b[0m\r\n");
status.textContent = "error";
};
ws.onclose = () => {
if (!["error", "ended"].includes(status.textContent)) {
status.textContent = "disconnected";
}
// If the session expired mid-use, fall back to the lock overlay.
authStatus().then((authed) => {
if (!authed) {
lockOverlay.style.display = "flex";
status.textContent = "locked";
}
});
};
};
term.onData((d) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data: d }));
}
});
const ro = new ResizeObserver(() => sendResize());
ro.observe(host);
restart.addEventListener("click", () => {
try {
ws?.close();
} catch (e) {
/* ignore */
}
term.reset();
ensureUnlocked().then((ok) => ok && connect());
});
ensureUnlocked().then((ok) => ok && connect());
node.__champTerm = {
dispose() {
try {
ro.disconnect();
} catch (e) {
/* ignore */
}
try {
ws?.close();
} catch (e) {
/* ignore */
}
try {
term.dispose();
} catch (e) {
/* ignore */
}
},
};
}
app.registerExtension({
name: "champdev.terminal",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "ChampdevTerminal") return;
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
setupTerminal(this).catch((err) =>
console.error("[champdev terminal] init failed:", err)
);
return r;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.__champTerm?.dispose();
return onRemoved?.apply(this, arguments);
};
},
});