Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ pnpm link
In-flight `lavish-axi poll` commands end with an interrupted-poll error and are safe to re-run; queued feedback is never lost, and annotation text you have typed but not queued yet survives the reload as described under **Live reload**.
A page waits for the replacement rather than reloading into a port nothing is listening on, and tells the user to restart Lavish if it never returns.
- **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
- **Landing page session list** - Visiting the server with no session key open lists every open session with its file name, a status dot, and a link. The list is empty until a session opens, and while populated it auto-refreshes every 15 seconds so it stays current.
- **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.
- **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
- **Network binding** - With Tailscale running, the review server automatically listens only on loopback (`127.0.0.1`) and this machine's Tailscale IPv4 address - never on `0.0.0.0` or every interface. The generated session link uses the machine's MagicDNS name, so it is the phone-ready URL to open from another device on the same tailnet. When Tailscale is absent or down, Lavish silently falls back to loopback-only and prints no phone URL. If Tailscale is running but MagicDNS is unavailable or its address cannot be bound after brief retries, Lavish visibly warns that phone access is unavailable and remains loopback-only. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to devices that can reach it, so the tailnet should be trusted. Any explicit `LAVISH_AXI_HOST` overrides automatic Tailscale binding; wildcard values are restricted to loopback, while a non-wildcard value selects that one concrete bind address. `LAVISH_AXI_LINK_HOST` controls the link host when automatic Tailscale binding is disabled.
Expand Down
4 changes: 2 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
import { findPlaybook, listPlaybooks, playbookIds, PLAYBOOK_ROUTER_HELP } from "./playbooks.js";
import { analyzeSelfPaint, SELF_PAINT_WARNING } from "./self-paint.js";
import { resolveDesignAssetPath, serve } from "./server.js";
import { canonicalFile, sessionKey, SessionStore } from "./session-store.js";
import { canonicalFile, filterVisibleSessions, sessionKey, SessionStore } from "./session-store.js";
import { generateSharePassword } from "./share-password.js";
import { initDefaultTelemetry } from "./telemetry.js";

Expand Down Expand Up @@ -1489,7 +1489,7 @@ async function serverCommand(args) {

async function visibleSessions() {
const store = new SessionStore(stateFile());
return (await store.listSessions()).filter((session) => session.status !== "ended");
return filterVisibleSessions(await store.listSessions());
}

async function assertHtmlFile(file) {
Expand Down
48 changes: 43 additions & 5 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import {
sanitizeListenHosts,
} from "./paths.js";
import { detectTailscale } from "./tailscale.js";
import { canonicalFile, SessionStore, sessionKey } from "./session-store.js";
import { canonicalFile, filterVisibleSessions, SessionStore, sessionKey } from "./session-store.js";
import { generateSharePassword } from "./share-password.js";
import {
ACCEPTED_IMAGE_MIME,
Expand Down Expand Up @@ -527,8 +527,16 @@ export async function serve({
return defaultJsonParser(req, res, next);
});

app.get("/", (_req, res) => {
res.type("html").send(createLandingHtml());
app.get("/", async (_req, res) => {
let sessions = [];
try {
sessions = filterVisibleSessions(await store.listSessions());
} catch {
// Fail open: the root route is a plain, unauthenticated surface that must
// never 500 or hang because state.json had a bad read - worst case it falls
// back to today's placeholder, same as if no sessions were open.
}
res.type("html").send(createLandingHtml(sessions));
});

app.get("/health", async (req, res) => {
Expand Down Expand Up @@ -1818,8 +1826,38 @@ function wantsHtml(req) {
return accept.toLowerCase().includes("text/html");
}

function createLandingHtml() {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lavish Editor</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f7f4ef;color:#25221f;font:16px/1.5 system-ui,sans-serif}.card{width:min(560px,calc(100% - 40px));padding:32px;border:1px solid #d9d0c5;border-radius:16px;background:#fffdf9;box-shadow:0 12px 40px #25221f18}h1{margin:0 0 12px;font-size:26px}p{margin:0}</style></head><body><main class="card"><h1>Lavish Editor is running</h1><p>Open the review session URL printed by your agent.</p></main></body></html>`;
// The landing/denied pages are their own self-contained light shell - a single
// inline <style> string, no link to chrome.css (an entirely separate dark
// ink/steel/brass system used only by /session/:key). Keeping them dependency-free
// is deliberate: these two pages are the only routes that render before a reviewer
// has any session open, so they must never depend on session-scoped assets.
function createLandingHtml(sessions = []) {
const body = sessions.length
? `<p class="sub">${sessions.length} active session${sessions.length === 1 ? "" : "s"} on this device:</p>` +
`<ul class="sessions">${sessions.map(createSessionRow).join("")}</ul>`
: `<p>Open the review session URL printed by your agent.</p>`;
// A newer artifact revision or a session ending elsewhere never reaches this
// static page on its own, so a populated list refreshes itself periodically to
// stay current. The empty placeholder never carries the tag: there is nothing
// there to go stale, and reloading it would disrupt someone leaving the tab open.
const refreshTag = sessions.length ? `<meta http-equiv="refresh" content="15">` : "";
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${refreshTag}<title>Lavish Editor</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f7f4ef;color:#25221f;font:16px/1.5 system-ui,sans-serif}.card{width:min(560px,calc(100% - 40px));padding:32px;border:1px solid #d9d0c5;border-radius:16px;background:#fffdf9;box-shadow:0 12px 40px #25221f18}h1{margin:0 0 12px;font-size:26px}p{margin:0}.sub{color:#948c7e;font-size:14px;margin:0 0 4px}.sessions{list-style:none;margin:14px 0 0;padding:0;border-top:1px solid #ece5da}.session-row{display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid #ece5da}.dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}.dot.open{background:#1baf7a}.dot.feedback{background:#c98a1f}.file{flex:1;min-width:0;display:flex;font:14px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#25221f}.path-head{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto;min-width:0;color:#a99f8f}.path-tail{flex:0 0 auto;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.open-link{flex:0 0 auto;color:#2a78d6;font-weight:700;text-decoration:none;font-size:14px}</style></head><body><main class="card"><h1>Lavish Editor is running</h1>${body}</main></body></html>`;
}

// The link is always built fresh from the session key, never from the session's
// stored `url` field: that field is captured once at POST /api/sessions time
// against whatever host was resolved then (src/server.js's /api/sessions route),
// and can go stale relative to whichever host actually served this landing page.
// /session/:key itself never checks Host, so a relative link resolves correctly
// against plain 127.0.0.1 today and the tailnet root once #216 lands.
function createSessionRow(session) {
const { head, tail } = displayPathParts(session.file);
const dotClass = session.status === "feedback" ? "feedback" : "open";
return (
`<li class="session-row"><span class="dot ${dotClass}" title="Status: ${escapeHtml(session.status)}"></span>` +
`<span class="file" title="${escapeHtml(session.file)}"><span class="path-head">${escapeHtml(head)}</span><span class="path-tail">${escapeHtml(tail)}</span></span>` +
`<a class="open-link" href="/session/${escapeHtml(session.key)}">Open &rarr;</a></li>`
);
}

function createDeniedHtml({ title, message, workingUrl }) {
Expand Down
7 changes: 7 additions & 0 deletions src/session-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,13 @@ export function sessionKey(file) {
return crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
}

// Sessions a human should be shown as "live" - the CLI's own home output and the
// server landing page both point at this so the two surfaces can never silently
// diverge on what "open" means.
export function filterVisibleSessions(sessions) {
return sessions.filter((session) => session.status !== "ended");
}

// Returns `{ prompt, malformed }`: `malformed` is non-empty when the payload's
// `attachments` field exists but cannot be honored as written, which fails the
// whole batch rather than being normalized away (C4, see queuePrompts).
Expand Down
129 changes: 129 additions & 0 deletions test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,20 @@ test("Tailscale mode binds concrete listeners, serves the MagicDNS link, and tea
});
assert.equal(landing.status, 200);
assert.match(landing.body, /Lavish Editor is running/);
// The session opened above (over the Tailscale listener) must show up in the
// landing list when it is fetched over the *other* allowed host (MagicDNS) -
// the list is sourced from state.json, not from whichever host is asking.
assert.match(landing.body, /artifact\.html/);
const sessionLinkMatch = landing.body.match(/href="(\/session\/[0-9a-f]+)"/);
assert.ok(sessionLinkMatch, "landing page links to the open session");
// The link must be relative (host-independent) so it resolves correctly no
// matter which allowed host rendered this page - proving #216 tailnet-root
// compatibility rather than merely asserting it.
const sessionLinkOnMagicDns = await rawRequest(server.port, sessionLinkMatch[1], {
host: `${magicDnsName}:${server.port}`,
headers: { accept: "text/html" },
});
assert.equal(sessionLinkOnMagicDns.status, 200);

const shutdown = await rawRequest(server.port, "/shutdown", {
method: "POST",
Expand All @@ -1718,6 +1732,121 @@ test("Tailscale mode binds concrete listeners, serves the MagicDNS link, and tea
}
});

test("landing page keeps today's placeholder verbatim when no sessions are open", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "lavish-landing-"));
const server = await serve({ port: 0, stateFile: path.join(dir, "state.json"), version: "9.9.9-test" });
try {
const landing = await rawRequest(server.port, "/", { headers: { accept: "text/html" } });
assert.equal(landing.status, 200);
assert.match(landing.body, /Lavish Editor is running/);
assert.match(landing.body, /Open the review session URL printed by your agent\./);
assert.doesNotMatch(landing.body, /class="sessions"/);
// The refresh tag exists only to keep a populated list current - an empty
// placeholder has nothing to go stale, so it must never reload the page
// someone left open in a tab for later.
assert.doesNotMatch(landing.body, /http-equiv="refresh"/);
} finally {
await server.close();
await rm(dir, { recursive: true, force: true });
}
});

test("landing page lists a single open session: file name, status, and a working link", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "lavish-landing-"));
const server = await serve({ port: 0, stateFile: path.join(dir, "state.json"), version: "9.9.9-test" });
try {
const artifact = path.join(dir, "artifact.html");
await writeFile(artifact, "<!doctype html><html><body>review</body></html>");
const opened = await fetch(`http://127.0.0.1:${server.port}/api/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ file: artifact }),
}).then((response) => response.json());

const landing = await rawRequest(server.port, "/", { headers: { accept: "text/html" } });
assert.equal(landing.status, 200);
assert.match(landing.body, /1 active session on this device:/);
assert.match(landing.body, /artifact\.html/);
assert.match(landing.body, new RegExp(`class="dot open" title="Status: open"`));
assert.match(landing.body, new RegExp(`href="/session/${opened.key}"`));
assert.match(landing.body, /http-equiv="refresh" content="15"/);

const sessionPage = await rawRequest(server.port, `/session/${opened.key}`, { headers: { accept: "text/html" } });
assert.equal(sessionPage.status, 200);
} finally {
await server.close();
await rm(dir, { recursive: true, force: true });
}
});

test("landing page lists several sessions and excludes an ended one", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "lavish-landing-"));
const server = await serve({ port: 0, stateFile: path.join(dir, "state.json"), version: "9.9.9-test" });
try {
const files = ["a.html", "b.html", "c.html"].map((name) => path.join(dir, name));
const opened = [];
for (const file of files) {
await writeFile(file, "<!doctype html><html><body>review</body></html>");
opened.push(
await fetch(`http://127.0.0.1:${server.port}/api/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ file }),
}).then((response) => response.json()),
);
}
const store = new SessionStore(path.join(dir, "state.json"));
await store.endSession(opened[1].key, "agent");

const landing = await rawRequest(server.port, "/", { headers: { accept: "text/html" } });
assert.equal(landing.status, 200);
assert.match(landing.body, /2 active sessions on this device:/);
assert.match(landing.body, /a\.html/);
assert.doesNotMatch(landing.body, /b\.html/);
assert.match(landing.body, /c\.html/);
} finally {
await server.close();
await rm(dir, { recursive: true, force: true });
}
});

test("landing page escapes a session file path containing markup-significant characters", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "lavish-landing-xss-"));
const stateFile = path.join(dir, "state.json");
const server = await serve({ port: 0, stateFile, version: "9.9.9-test" });
try {
// Windows forbids `<>:"|?*` in path names, so the XSS payload cannot live
// on disk. Open a real artifact under a safe path, then rewrite the stored
// `file` the landing page renders — that field is what must be escaped.
const artifact = path.join(dir, "artifact.html");
await writeFile(artifact, "<!doctype html><html><body>review</body></html>");
const opened = await fetch(`http://127.0.0.1:${server.port}/api/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ file: artifact }),
}).then((response) => response.json());

const state = JSON.parse(await readFile(stateFile, "utf8"));
// Do not path.join the XSS payload: Windows treats `/` in `</script>` as a
// separator, so the stored path would become `...<\script>...` and the
// title-attribute assertion looking for `&lt;/script&gt;` would fail.
const evilFile = `${dir}${path.sep}<script>alert(1)</script>&"'${path.sep}artifact.html`;
state.sessions[opened.key].file = evilFile;
await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);

const landing = await rawRequest(server.port, "/", { headers: { accept: "text/html" } });
assert.equal(landing.status, 200);
assert.doesNotMatch(landing.body, /<script>alert\(1\)<\/script>/);
assert.match(landing.body, /&lt;script&gt;alert\(1\)&lt;\/script&gt;&amp;&quot;&#39;/);
// The path is interpolated into a title="..." attribute; unescaped quotes
// would terminate it. The escaped payload must sit inside one attribute.
assert.match(landing.body, /title="[^"]*&lt;script&gt;alert\(1\)&lt;\/script&gt;&amp;&quot;&#39;[^"]*"/);
} finally {
await server.close();
await rm(dir, { recursive: true, force: true });
}
});

function connectTo(host, port) {
return new Promise((resolve, reject) => {
const socket = netConnect({ host, port });
Expand Down
11 changes: 11 additions & 0 deletions test/session-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import test from "node:test";

import {
ATTACHMENT_DELIVERY_GRACE_MS,
filterVisibleSessions,
MAX_DELIVERED_ATTACHMENTS,
MAX_REQUEST_ATTACHMENT_REFS,
SessionStore,
Expand Down Expand Up @@ -2097,3 +2098,13 @@ test("every image delivered in one poll survives, across accumulated batches (po
assert.deepEqual(missing, [], `every id in the actual delivery stays referenced (${missing.length} were not)`);
});
});

test("filterVisibleSessions keeps open and feedback sessions, drops ended ones, preserves order", () => {
const sessions = [
{ file: "/a.html", status: "open" },
{ file: "/b.html", status: "ended" },
{ file: "/c.html", status: "feedback" },
{ file: "/d.html", status: "ended" },
];
assert.deepEqual(filterVisibleSessions(sessions), [sessions[0], sessions[2]]);
});
Loading