diff --git a/lib/public/modules/sticky-notes-fmt.js b/lib/public/modules/sticky-notes-fmt.js index 20df067..b524690 100644 --- a/lib/public/modules/sticky-notes-fmt.js +++ b/lib/public/modules/sticky-notes-fmt.js @@ -7,13 +7,41 @@ // sticky-notes.js (it is browser-ESM with DOM deps). This module mirrors its exact // implementation so that the regression tests call the real production logic pattern. // Any change to fmt() in sticky-notes.js must be reflected here. -export function fmt(s) { - var escaped = s + +// The URL auto-link regex used in fmt(), exported for direct inspection in tests. +// Excludes whitespace, HTML special chars, and quote chars from URL matches so that +// a crafted URL like http://x.com"onmouseover="alert(1) cannot break out of the href. +export var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g; + +function escapeHtmlBasic(s) { + return s .replace(/&/g, "&") .replace(//g, ">"); - return escaped - .replace(/(https?:\/\/[^\s<>"']+)/g, '$1') + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function fmt(s) { + // Split on raw URLs first (before any HTML escaping), so quote chars in the + // surrounding text still act as natural URL terminators in AUTO_LINK_RE. + // Non-URL segments are HTML-escaped; URL segments are escaped then wrapped in + // an anchor (the href value is also escaped so &, <, > inside URLs are safe). + AUTO_LINK_RE.lastIndex = 0; + var parts = s.split(AUTO_LINK_RE); + // split() with a capturing group produces: [text, url, text, url, ...] + var out = ""; + for (var i = 0; i < parts.length; i++) { + if (i % 2 === 0) { + // Non-URL segment — HTML-escape fully. + out += escapeHtmlBasic(parts[i]); + } else { + // URL segment — escape for safe embedding in href and link text. + var safeUrl = escapeHtmlBasic(parts[i]); + out += '' + safeUrl + ""; + } + } + return out .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/(?$1") .replace(/`([^`]+)`/g, "$1") @@ -22,6 +50,3 @@ export function fmt(s) { .replace(/^- \[ \]/gm, '') .replace(/\n/g, "
"); } - -// The URL auto-link regex used in fmt(), exported for direct inspection in tests. -export var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g; diff --git a/lib/public/modules/sticky-notes.js b/lib/public/modules/sticky-notes.js index cabeb99..d0dc136 100644 --- a/lib/public/modules/sticky-notes.js +++ b/lib/public/modules/sticky-notes.js @@ -150,13 +150,31 @@ function renderMiniMarkdown(text) { var title = lines[0]; var body = lines.slice(1).join("\n"); - function fmt(s) { - var escaped = s + var AUTO_LINK_RE = /(https?:\/\/[^\s<>"']+)/g; + function escHtml(s) { + return s .replace(/&/g, "&") .replace(//g, ">"); - return escaped - .replace(/(https?:\/\/[^\s<>"']+)/g, '$1') + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + function fmt(s) { + // Split on raw URLs first so quote chars still terminate URL matches even + // after HTML-escaping. Non-URL segments are fully escaped; URL segments are + // escaped and wrapped in an anchor. + AUTO_LINK_RE.lastIndex = 0; + var parts = s.split(AUTO_LINK_RE); + var out = ""; + for (var i = 0; i < parts.length; i++) { + if (i % 2 === 0) { + out += escHtml(parts[i]); + } else { + var safeUrl = escHtml(parts[i]); + out += '' + safeUrl + ""; + } + } + return out .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/(?$1") .replace(/`([^`]+)`/g, "$1") diff --git a/test/boot-smoke-lr-1a5f.test.js b/test/boot-smoke-lr-1a5f.test.js new file mode 100644 index 0000000..c72b7f3 --- /dev/null +++ b/test/boot-smoke-lr-1a5f.test.js @@ -0,0 +1,222 @@ +// boot-smoke-lr-1a5f.test.js +// +// Daemon boot smoke test (lr-1a5f). +// +// What this test proves (server-side only): +// 1. The daemon starts and its HTTP server responds on /info (boot success). +// 2. The frontend HTML is served with HTTP 200 (static asset pipeline works). +// 3. A WebSocket upgrade to the project endpoint succeeds (server-side WS +// handler wired up, auth gate passes, relay code loaded without errors). +// +// What this test does NOT prove: +// - That browser-side ESM (lib/public/*.js) loads without errors. +// Browser ESM imports are NOT loaded by the daemon; they are served as +// static assets. Broken browser imports (the lr-8657 failure class) are +// caught by the static import-resolution check (lr-5e24), not here. +// - That the full UI renders correctly (integration/E2E territory). +// +// The test FAILS if any of the three server-side checks fail. +// The test is isolated: uses an ephemeral port + a tmpdir home, never touches +// the real ~/.clagentic directory. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); +var os = require("os"); +var net = require("net"); +var http = require("http"); +var { spawn } = require("child_process"); +var WebSocket = require("ws"); + +// ── constants ──────────────────────────────────────────────────────────────── + +var DAEMON_SCRIPT = path.resolve(__dirname, "..", "lib", "daemon.js"); +var TEST_TIMEOUT_MS = 45000; +var DAEMON_READY_MS = 20000; +var WS_CONNECT_MS = 10000; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function findFreePort() { + return new Promise(function (resolve, reject) { + var srv = net.createServer(); + srv.listen(0, "127.0.0.1", function () { + var p = srv.address().port; + srv.close(function () { resolve(p); }); + }); + srv.on("error", reject); + }); +} + +function waitForServer(port, timeoutMs) { + var start = Date.now(); + return new Promise(function (resolve, reject) { + function attempt() { + if (Date.now() - start > timeoutMs) { + reject(new Error("daemon did not respond on /info within " + timeoutMs + " ms")); + return; + } + var req = http.get("http://127.0.0.1:" + port + "/info", function (res) { + res.resume(); + if (res.statusCode === 200) { resolve(); } + else { setTimeout(attempt, 250); } + }); + req.on("error", function () { setTimeout(attempt, 250); }); + req.setTimeout(500, function () { req.destroy(); }); + } + attempt(); + }); +} + +function httpGet(url) { + return new Promise(function (resolve, reject) { + var req = http.get(url, function (res) { + var body = ""; + res.setEncoding("utf8"); + res.on("data", function (c) { body += c; }); + res.on("end", function () { resolve({ status: res.statusCode, body: body }); }); + }); + req.on("error", reject); + req.setTimeout(5000, function () { req.destroy(new Error("httpGet timeout")); }); + }); +} + +function killAndWait(proc) { + return new Promise(function (resolve) { + if (proc.exitCode !== null) { resolve(); return; } + proc.once("exit", resolve); + try { proc.kill("SIGTERM"); } catch (_) {} + setTimeout(function () { + try { proc.kill("SIGKILL"); } catch (_) {} + resolve(); + }, 4000); + }); +} + +// ── test ───────────────────────────────────────────────────────────────────── + +test("boot smoke: daemon starts, HTTP 200, WS connects (lr-1a5f)", { timeout: TEST_TIMEOUT_MS }, function (t, done) { + var tmpHome = null; + var daemonProc = null; + var daemonLog = []; + + // t.after: reliable cleanup even if the outer timeout fires. + t.after(function () { + var p = daemonProc && daemonProc.exitCode === null + ? killAndWait(daemonProc).catch(function (e) { t.diagnostic("t.after daemon kill: " + e.message); }) + : Promise.resolve(); + return p.then(function () { + if (tmpHome) { + try { fs.rmSync(tmpHome, { recursive: true, force: true }); } + catch (e) { t.diagnostic("t.after tmpdir cleanup failed: " + e.message + " (leaked: " + tmpHome + ")"); } + } + }); + }); + + findFreePort().then(function (port) { + // ── 1. Isolated home + minimal config ──────────────────────────────────── + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-smoke-")); + var projectDir = path.join(tmpHome, "smoke-project"); + fs.mkdirSync(projectDir, { recursive: true }); + + var configFile = path.join(tmpHome, "daemon.json"); + fs.writeFileSync(configFile, JSON.stringify({ + port: port, + host: "127.0.0.1", + tls: false, + pinHash: null, + mode: "single", + setupCompleted: true, + debug: false, + projects: [{ path: projectDir, slug: "smoke-project", addedAt: Date.now() }], + }, null, 2), { mode: 0o600 }); + + // ── 2. Spawn daemon ─────────────────────────────────────────────────────── + daemonProc = spawn(process.execPath, [DAEMON_SCRIPT], { + env: Object.assign({}, process.env, { + CLAGENTIC_HOME: tmpHome, + CLAGENTIC_CONFIG: configFile, + }), + stdio: ["ignore", "pipe", "pipe"], + }); + daemonProc.stdout.on("data", function (d) { daemonLog.push(d.toString()); }); + daemonProc.stderr.on("data", function (d) { daemonLog.push("[err] " + d.toString()); }); + daemonProc.once("exit", function (code) { + if (code !== 0 && code !== null) { + t.diagnostic("daemon exited with code " + code); + t.diagnostic("daemon log:\n" + daemonLog.slice(-30).join("")); + } + }); + + // ── 3. Check 1 — HTTP /info responds ───────────────────────────────────── + return waitForServer(port, DAEMON_READY_MS).then(function () { return port; }); + + }).then(function (port) { + // ── 4. Check 2 — frontend HTML served with 200 ─────────────────────────── + return httpGet("http://127.0.0.1:" + port + "/p/smoke-project/").then(function (res) { + assert.strictEqual(res.status, 200, + "Expected HTTP 200 for frontend page, got " + res.status); + assert.ok( + res.body.includes("