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("