lavish-axi 0.1.49 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -217,7 +217,7 @@ pnpm link
217
217
  - **Diagnostic viewports** - `LAVISH_AXI_DIAGNOSTIC_VIEWPORTS` sets which viewport classes the layout-issue inbox tracks (`mobile`, `compact`, `desktop`; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed.
218
218
  - **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
219
219
  - **Network binding** - The server binds to loopback (`127.0.0.1`) by default. Set `LAVISH_AXI_HOST` to bind elsewhere; a wildcard (`0.0.0.0` or `::`) binds every interface. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. Set `LAVISH_AXI_LINK_HOST` to control the hostname written into generated session links (defaults to the bind address, or loopback when bound to a wildcard).
220
- - **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely (only when the server sits behind your own authentication or proxy).
220
+ - **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it together with `X-Forwarded-Proto`. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely (only when the server sits behind your own authentication or proxy).
221
221
  - **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
222
222
 
223
223
  ## CLI Reference
@@ -1293,7 +1293,8 @@ function showWhiteboardOverlay(index) {
1293
1293
  postToFrame({ type: "lavish:suspendWhiteboard", diagramIndex: index });
1294
1294
  // A fresh document per open: the frame boots, posts ready, and receives its
1295
1295
  // init - no stale editor state can leak between opens.
1296
- whiteboardFrame.src = "/whiteboard-frame?diagramIndex=" + encodeURIComponent(String(index));
1296
+ whiteboardFrame.src =
1297
+ "/whiteboard-frame?diagramIndex=" + encodeURIComponent(String(index)) + "&key=" + encodeURIComponent(key);
1297
1298
  }
1298
1299
 
1299
1300
  function finishWhiteboardClose(index) {
@@ -1573,10 +1574,30 @@ function handleAuthenticatedWhiteboardMessage(index, message, mode) {
1573
1574
  if (message.type === "lavish-whiteboard:flushComplete") finishWhiteboardFlush(index, message, mode);
1574
1575
  }
1575
1576
 
1577
+ // Inline whiteboard frames are created by the SDK inside the artifact document,
1578
+ // so a genuine one is always a direct child of the *current* artifact window.
1579
+ // Descent - not the channel token - is what proves the sender is ours: the
1580
+ // frame page is framable by any origin, so a token is not a secret an attacker
1581
+ // cannot obtain. Without this, any window that could postMessage to this chrome
1582
+ // (a page that framed it, or one holding a window.open handle) could open a
1583
+ // channel and queue a fabricated prompt. Mirrors the artifact-message handler's
1584
+ // `event.source !== frame.contentWindow` guard.
1585
+ function isArtifactChildWindow(source) {
1586
+ if (!source) return false;
1587
+ try {
1588
+ // Reading `parent` on a cross-origin WindowProxy is permitted; the frame's
1589
+ // sandbox makes everything else about it opaque.
1590
+ return source.parent === frame.contentWindow;
1591
+ } catch {
1592
+ return false;
1593
+ }
1594
+ }
1595
+
1576
1596
  function handleInlineWhiteboardMessage(event, message) {
1577
1597
  if (ended) return;
1598
+ if (!isArtifactChildWindow(event.source)) return;
1578
1599
  const index = validWhiteboardIndex(message.diagramIndex);
1579
- if (index === null || !event.source) return;
1600
+ if (index === null) return;
1580
1601
  if (message.type === "lavish-whiteboard:ready") {
1581
1602
  if (inlineWhiteboardChannels.has(index)) return;
1582
1603
  const channelId = String(message.channelToken || "");
package/dist/cli.mjs CHANGED
@@ -4497,6 +4497,7 @@ import crypto4 from "node:crypto";
4497
4497
  import { EventEmitter } from "node:events";
4498
4498
  import { existsSync as existsSync2 } from "node:fs";
4499
4499
  import { readFile as readFile4, realpath as realpath3 } from "node:fs/promises";
4500
+ import { isIP } from "node:net";
4500
4501
  import { homedir } from "node:os";
4501
4502
  import path6 from "node:path";
4502
4503
  import { fileURLToPath as fileURLToPath3 } from "node:url";
@@ -4755,7 +4756,7 @@ function isNearTotalOcclusion({ occludedSamples, totalSamples, minSamples = 5, m
4755
4756
  const total = Number(totalSamples);
4756
4757
  return Number.isFinite(occluded) && Number.isFinite(total) && total >= minSamples && occluded / total >= minRatio;
4757
4758
  }
4758
- function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl, mermaid = mermaid_node_exports, artifactRevision = 0, artifactLoadToken = "") {
4759
+ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl, mermaid = mermaid_node_exports, artifactRevision = 0, artifactLoadToken = "", sessionKey2 = "") {
4759
4760
  const { isMermaidSvg: isMermaidSvg2, mermaidNodeFrom: mermaidNodeFrom2, mermaidNodeElement: mermaidNodeElement2 } = mermaid;
4760
4761
  function postArtifactMessage(type, payload = {}) {
4761
4762
  parent.postMessage({ type, ...payload, artifact_load_token: String(artifactLoadToken || "") }, "*");
@@ -4950,7 +4951,10 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4950
4951
  function whiteboardFrameSrc(entry) {
4951
4952
  const params = new URLSearchParams({
4952
4953
  diagramIndex: String(entry.index),
4953
- diagramId: String(entry.diagramId || "")
4954
+ diagramId: String(entry.diagramId || ""),
4955
+ // The frame's channel token is bound to this session, so the frame page
4956
+ // must be told which session it belongs to.
4957
+ key: String(sessionKey2 || "")
4954
4958
  });
4955
4959
  return `/whiteboard-frame?${params}`;
4956
4960
  }
@@ -7315,18 +7319,22 @@ function defaultWhiteboardAssetsDir() {
7315
7319
  function isWhiteboardWriteApiPath(pathname) {
7316
7320
  return /^\/api\/[0-9a-f]{16}\/whiteboard\/\d{1,3}(\/feedback-files)?$/.test(String(pathname || ""));
7317
7321
  }
7318
- function createWhiteboardChannelToken(secret, now = Date.now()) {
7319
- const payload = `${now}.${crypto4.randomBytes(24).toString("base64url")}`;
7320
- const signature = crypto4.createHmac("sha256", secret).update(payload).digest("base64url");
7321
- return `${payload}.${signature}`;
7322
+ function whiteboardChannelPayload(issuedAt, nonce, sessionKey2) {
7323
+ return `${issuedAt}.${nonce}.${sessionKey2}`;
7322
7324
  }
7323
- function isValidWhiteboardChannelToken(token, secret, now = Date.now()) {
7325
+ function createWhiteboardChannelToken(secret, sessionKey2, now = Date.now()) {
7326
+ const nonce = crypto4.randomBytes(24).toString("base64url");
7327
+ const signature = crypto4.createHmac("sha256", secret).update(whiteboardChannelPayload(now, nonce, String(sessionKey2 || ""))).digest("base64url");
7328
+ return `${now}.${nonce}.${signature}`;
7329
+ }
7330
+ function isValidWhiteboardChannelToken(token, secret, sessionKey2, now = Date.now()) {
7331
+ if (!isValidWhiteboardKey(sessionKey2)) return false;
7324
7332
  const [issuedAtText, nonce, signature, extra] = String(token || "").split(".");
7325
7333
  if (extra !== void 0 || !/^\d{13}$/.test(issuedAtText) || !/^[A-Za-z0-9_-]{32}$/.test(nonce)) return false;
7326
7334
  const issuedAt = Number(issuedAtText);
7327
7335
  if (!Number.isSafeInteger(issuedAt) || issuedAt > now || now - issuedAt > WHITEBOARD_CHANNEL_TOKEN_TTL_MS)
7328
7336
  return false;
7329
- const expected = crypto4.createHmac("sha256", secret).update(`${issuedAtText}.${nonce}`).digest("base64url");
7337
+ const expected = crypto4.createHmac("sha256", secret).update(whiteboardChannelPayload(issuedAtText, nonce, String(sessionKey2))).digest("base64url");
7330
7338
  const actualBuffer = Buffer.from(signature || "", "utf8");
7331
7339
  const expectedBuffer = Buffer.from(expected, "utf8");
7332
7340
  return actualBuffer.length === expectedBuffer.length && crypto4.timingSafeEqual(actualBuffer, expectedBuffer);
@@ -7369,7 +7377,8 @@ async function serve({
7369
7377
  let publicPort = port;
7370
7378
  const whiteboardStateRoot = path6.dirname(stateFile2);
7371
7379
  const allowedHostnames = buildAllowedHostnames({ host, linkHost: linkHostName, allowedHosts });
7372
- if (!allowsAllHosts(allowedHosts)) {
7380
+ const allowAnyHostname = allowsAllHosts(allowedHosts);
7381
+ if (!allowAnyHostname) {
7373
7382
  app.use((req, res, next) => {
7374
7383
  const requestHost = { host: req.headers.host, forwardedHost: req.headers["x-forwarded-host"] };
7375
7384
  if (isAllowedRequestHost(requestHost, allowedHostnames)) {
@@ -7497,6 +7506,10 @@ async function serve({
7497
7506
  });
7498
7507
  app.post("/api/:key/prompts", async (req, res, next) => {
7499
7508
  try {
7509
+ if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
7510
+ res.status(403).json({ error: "cross-origin prompt submission rejected" });
7511
+ return;
7512
+ }
7500
7513
  const shouldEndSession = Boolean(req.body?.endSession || req.body?.end_session);
7501
7514
  const hasLayoutWarningPrompt = Array.isArray(req.body?.prompts) ? req.body.prompts.some((prompt) => prompt?.tag === "layout-warnings") : false;
7502
7515
  const session = await store.queuePrompts(req.params.key, req.body || {});
@@ -7654,7 +7667,7 @@ async function serve({
7654
7667
  });
7655
7668
  app.post("/api/:key/share", async (req, res, next) => {
7656
7669
  try {
7657
- if (!isSameOriginRequest(req)) {
7670
+ if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
7658
7671
  res.status(403).json({ error: "cross-origin share request rejected" });
7659
7672
  return;
7660
7673
  }
@@ -7713,6 +7726,8 @@ async function serve({
7713
7726
  await watchSession(session, watchers, events, logEvent, reloadDebounceMs);
7714
7727
  const artifactHtml = await readFile4(session.file, "utf8").catch(() => "");
7715
7728
  const { faviconTag, title } = extractArtifactHead(artifactHtml);
7729
+ res.setHeader("x-frame-options", "DENY");
7730
+ res.setHeader("content-security-policy", "frame-ancestors 'none'");
7716
7731
  res.type("html").send(
7717
7732
  createChromeHtml(session, {
7718
7733
  layoutGateEnabled: shouldEnableLayoutGate(req.query || {}),
@@ -7733,7 +7748,7 @@ async function serve({
7733
7748
  });
7734
7749
  app.post("/api/:key/chrome-loads/begin", async (req, res, next) => {
7735
7750
  try {
7736
- if (!isSameOriginRequest(req)) {
7751
+ if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
7737
7752
  res.status(403).json({ error: "cross-origin chrome handoff rejected" });
7738
7753
  return;
7739
7754
  }
@@ -7934,7 +7949,12 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
7934
7949
  });
7935
7950
  app.get("/whiteboard-frame", (req, res) => {
7936
7951
  res.setHeader("cache-control", "no-store");
7937
- res.type("html").send(createWhiteboardFrameHtml(createWhiteboardChannelToken(whiteboardChannelSecret)));
7952
+ const sessionKey2 = String(req.query.key || "");
7953
+ if (!isValidWhiteboardKey(sessionKey2)) {
7954
+ res.status(400).type("text/plain").send("Missing session key");
7955
+ return;
7956
+ }
7957
+ res.type("html").send(createWhiteboardFrameHtml(createWhiteboardChannelToken(whiteboardChannelSecret, sessionKey2)));
7938
7958
  });
7939
7959
  app.get(/^\/whiteboard-assets\/(.+)$/, async (req, res, next) => {
7940
7960
  try {
@@ -7987,7 +8007,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
7987
8007
  });
7988
8008
  app.post("/api/:key/whiteboard-channel", async (req, res, next) => {
7989
8009
  try {
7990
- if (!isSameOriginRequest(req)) {
8010
+ if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
7991
8011
  res.status(403).json({ error: "cross-origin whiteboard channel request rejected" });
7992
8012
  return;
7993
8013
  }
@@ -7996,7 +8016,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
7996
8016
  res.status(404).json({ error: "session not found" });
7997
8017
  return;
7998
8018
  }
7999
- if (!isValidWhiteboardChannelToken(req.body?.token, whiteboardChannelSecret)) {
8019
+ if (!isValidWhiteboardChannelToken(req.body?.token, whiteboardChannelSecret, req.params.key)) {
8000
8020
  res.status(403).json({ error: "invalid whiteboard channel" });
8001
8021
  return;
8002
8022
  }
@@ -8007,7 +8027,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
8007
8027
  });
8008
8028
  app.put("/api/:key/whiteboard/:index", async (req, res, next) => {
8009
8029
  try {
8010
- if (!isSameOriginRequest(req)) {
8030
+ if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
8011
8031
  res.status(403).json({ error: "cross-origin whiteboard write rejected" });
8012
8032
  return;
8013
8033
  }
@@ -8030,7 +8050,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
8030
8050
  });
8031
8051
  app.post("/api/:key/whiteboard/:index/feedback-files", async (req, res, next) => {
8032
8052
  try {
8033
- if (!isSameOriginRequest(req)) {
8053
+ if (!isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
8034
8054
  res.status(403).json({ error: "cross-origin whiteboard write rejected" });
8035
8055
  return;
8036
8056
  }
@@ -8186,27 +8206,37 @@ function buildAllowedHostnames({ host, linkHost: linkHostName, allowedHosts = []
8186
8206
  function allowsAllHosts(allowedHosts = []) {
8187
8207
  return allowedHosts.some((value) => String(value).trim() === "*");
8188
8208
  }
8189
- function hostnameFromHostHeader(value) {
8209
+ function parseHostAuthority(value) {
8190
8210
  const raw = String(value).trim();
8211
+ if (!raw || /[@/\\?#\s]/.test(raw)) return null;
8212
+ let hostname;
8213
+ let port;
8214
+ let bracketed = false;
8191
8215
  if (raw.startsWith("[")) {
8192
- const end = raw.indexOf("]");
8193
- if (end === -1) return null;
8194
- const rest = raw.slice(end + 1);
8195
- if (rest.length > 0 && !rest.startsWith(":")) return null;
8196
- return raw.slice(1, end).toLowerCase();
8216
+ const match = /^\[([0-9A-Fa-f:.]+)\](?::(\d+))?$/.exec(raw);
8217
+ if (!match || isIP(match[1]) !== 6) return null;
8218
+ [, hostname, port = ""] = match;
8219
+ bracketed = true;
8220
+ } else {
8221
+ const match = /^([A-Za-z0-9._-]+)(?::(\d+))?$/.exec(raw);
8222
+ if (!match) return null;
8223
+ [, hostname, port = ""] = match;
8197
8224
  }
8198
- const colon = raw.indexOf(":");
8199
- const hostname = colon === -1 ? raw : raw.slice(0, colon);
8200
- if (hostname.includes(":")) return null;
8201
- return hostname.toLowerCase();
8225
+ if (port && Number(port) > 65535) return null;
8226
+ hostname = hostname.toLowerCase();
8227
+ const authority = `${bracketed ? `[${hostname}]` : hostname}${port ? `:${port}` : ""}`;
8228
+ try {
8229
+ const parsed = new URL(`http://${authority}`);
8230
+ if (!parsed.origin || parsed.origin === "null") return null;
8231
+ } catch {
8232
+ return null;
8233
+ }
8234
+ return { hostname, port, authority };
8202
8235
  }
8203
8236
  function isAllowedHostHeader(hostHeader, allowedHostnames) {
8204
8237
  if (hostHeader === void 0 || hostHeader === null) return false;
8205
- const raw = String(hostHeader).trim();
8206
- if (raw === "") return false;
8207
- const hostname = hostnameFromHostHeader(raw);
8208
- if (hostname === null) return false;
8209
- return allowedHostnames.has(hostname);
8238
+ const authority = parseHostAuthority(hostHeader);
8239
+ return authority !== null && allowedHostnames.has(authority.hostname);
8210
8240
  }
8211
8241
  function isAllowedRequestHost({ host, forwardedHost }, allowedHostnames) {
8212
8242
  if (!isAllowedHostHeader(host, allowedHostnames)) return false;
@@ -8214,8 +8244,22 @@ function isAllowedRequestHost({ host, forwardedHost }, allowedHostnames) {
8214
8244
  if (forwarded === "") return true;
8215
8245
  return isAllowedHostHeader(forwarded.split(",").pop(), allowedHostnames);
8216
8246
  }
8217
- function isSameOriginRequest(req) {
8218
- const expectedOrigin = `${req.protocol}://${req.get("host")}`;
8247
+ function isSameOriginRequest(req, allowedHostnames, allowAnyHostname = false) {
8248
+ const host = parseHostAuthority(req.headers.host);
8249
+ if (!host) return false;
8250
+ let protocol = req.protocol;
8251
+ let authority = host;
8252
+ const forwardedHost = String(req.get("x-forwarded-host") || "").split(",").pop().trim();
8253
+ if (forwardedHost) {
8254
+ const forwardedAuthority = parseHostAuthority(forwardedHost);
8255
+ if (!forwardedAuthority || !allowAnyHostname && (!allowedHostnames.has(host.hostname) || !allowedHostnames.has(forwardedAuthority.hostname)))
8256
+ return false;
8257
+ protocol = String(req.get("x-forwarded-proto") || req.protocol).split(",").pop().trim().toLowerCase();
8258
+ if (protocol !== "http" && protocol !== "https") return false;
8259
+ authority = forwardedAuthority;
8260
+ }
8261
+ const expectedOrigin = normalizeOrigin(`${protocol}://${authority.authority}`);
8262
+ if (!expectedOrigin) return false;
8219
8263
  const origin = req.get("origin");
8220
8264
  if (origin) {
8221
8265
  return normalizeOrigin(origin) === expectedOrigin;
@@ -8525,7 +8569,6 @@ function createSdkJs(key, artifactRevision = 0, artifactLoadToken = "") {
8525
8569
  const loadToken = String(artifactLoadToken || "").slice(0, 200);
8526
8570
  return `(() => {
8527
8571
  const key=${JSON.stringify(key)};
8528
- void key;
8529
8572
  const artifactRevision=${revision};
8530
8573
  const artifactLoadToken=${JSON.stringify(loadToken)};
8531
8574
  const deriveQueueKey=${deriveLavishQueueKey.toString()};
@@ -8539,7 +8582,7 @@ const findStableLayoutFindings=${findStableLayoutFindings.toString()};
8539
8582
  const isNearTotalOcclusion=${isNearTotalOcclusion.toString()};
8540
8583
  ${mermaidHelperDecls}
8541
8584
  const mermaidHelpers={ ${mermaidHelperKeys} };
8542
- (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl, mermaidHelpers, artifactRevision, artifactLoadToken);
8585
+ (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl, mermaidHelpers, artifactRevision, artifactLoadToken, key);
8543
8586
  })();`;
8544
8587
  }
8545
8588
  function escapeHtml(value) {
@@ -8735,7 +8778,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
8735
8778
  ]);
8736
8779
  var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited.";
8737
8780
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
8738
- var VERSION = "0.1.49";
8781
+ var VERSION = "0.1.50";
8739
8782
  function detectInvokingAgent(env = process.env) {
8740
8783
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
8741
8784
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.49",
4
+ "version": "0.1.50",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",