feat: spawn headless claude ingest with filename validation

This commit is contained in:
meels
2026-07-28 14:15:49 +02:00
parent 2bb0be0a64
commit 79d3240aa2
3 changed files with 150 additions and 2 deletions

View File

@@ -156,6 +156,19 @@ function reconcileConcepts(rows, conceptNames) {
};
}
// `shell: true` is required on Windows to resolve `claude.cmd`, which puts the
// filename into a shell string. Reject anything cmd.exe or a POSIX shell would
// interpret. Apostrophes and cyrillic are safe inside double quotes and are
// present in real filenames, so they stay allowed.
const UNSAFE_CHARS = /["`$&|;<>%\r\n]/;
function isSafeFilename(name) {
if (typeof name !== "string" || name.length === 0) return false;
if (UNSAFE_CHARS.test(name)) return false;
if (name.includes("..")) return false;
return true;
}
function parseConfig(source) {
const cfg = Object.assign({}, DEFAULTS);
for (const line of String(source).split(/\r?\n/)) {
@@ -341,7 +354,7 @@ class WebinarDashPlugin extends PluginBase {
const root = el.createDiv({ cls: "webinar-dash" });
try {
const pipeline = await this.readPipeline(cfg);
renderLeftPane(root, pipeline, () => {});
renderLeftPane(root, pipeline, (file, rowEl) => this.runIngest(file, rowEl));
} catch (err) {
root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
}
@@ -390,6 +403,91 @@ class WebinarDashPlugin extends PluginBase {
return derivePipeline({ rawFiles, sourcePages });
}
vaultPath() {
const adapter = this.app.vault.adapter;
if (typeof adapter.getBasePath === "function") return adapter.getBasePath();
return null;
}
runIngest(file, rowEl) {
if (rowEl.dataset.wdRunning === "1") return;
const Notice = OB ? OB.Notice : null;
const notify = (msg) => { if (Notice) new Notice(msg); };
if (!isSafeFilename(file.name)) {
rowEl.createDiv({
cls: "wd-output",
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
});
return;
}
const base = this.vaultPath();
if (!base) {
notify("Ingest needs desktop Obsidian.");
return;
}
let spawn;
try {
({ spawn } = require("child_process"));
} catch (_) {
notify("child_process unavailable — ingest needs desktop Obsidian.");
return;
}
rowEl.dataset.wdRunning = "1";
const button = rowEl.querySelector("button");
if (button) button.disabled = true;
const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
const started = Date.now();
const timer = window.setInterval(() => {
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
}, 1000);
const output = rowEl.createDiv({ cls: "wd-output", text: "" });
let buffered = "";
const append = (chunk) => {
buffered += chunk.toString();
output.setText(buffered.slice(-4000));
output.scrollTop = output.scrollHeight;
};
const child = spawn("claude", ["-p", `ingest "${file.name}"`], {
cwd: base,
shell: true,
});
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", (err) => {
window.clearInterval(timer);
status.className = "wd-status wd-status-failed";
status.setText("failed");
append(`\nCould not start claude: ${err.message}\nIs it on PATH?`);
rowEl.dataset.wdRunning = "0";
if (button) button.disabled = false;
});
child.on("close", (code) => {
window.clearInterval(timer);
const secs = Math.round((Date.now() - started) / 1000);
if (code === 0) {
status.className = "wd-status wd-status-done";
status.setText(`done in ${secs}s`);
notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`);
} else {
status.className = "wd-status wd-status-failed";
status.setText(`failed - exit ${code}`);
}
rowEl.dataset.wdRunning = "0";
if (button) button.disabled = false;
});
}
}
module.exports = WebinarDashPlugin;
@@ -397,5 +495,5 @@ module.exports.default = WebinarDashPlugin;
module.exports.__test__ = {
parseConfig, extractRawPath, derivePipeline,
parseCoverageTable, groupByStation, reconcileConcepts,
STATIONS, DEFAULTS,
STATIONS, DEFAULTS, isSafeFilename,
};