summaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/bench-pure.ts71
-rw-r--r--scripts/live-probe-provider-retry.ts4
-rw-r--r--scripts/live-probe.ts4
-rw-r--r--scripts/network-policy.test.ts136
-rw-r--r--scripts/network-policy.ts165
-rw-r--r--scripts/probe-replay.ts36
-rw-r--r--scripts/probe-tab.ts113
-rw-r--r--scripts/text-file-environment.ts19
-rw-r--r--scripts/text-file-live.test.ts119
-rw-r--r--scripts/vite-network-guard.test.ts117
-rw-r--r--scripts/vite-network-guard.ts74
11 files changed, 854 insertions, 4 deletions
diff --git a/scripts/bench-pure.ts b/scripts/bench-pure.ts
new file mode 100644
index 0000000..0bff5a1
--- /dev/null
+++ b/scripts/bench-pure.ts
@@ -0,0 +1,71 @@
+/**
+ * Bench the PURE pipeline a tab runs when opening a conversation mid-turn:
+ * history fetch → applyHistory → fold every replayed event (one render-pass
+ * worth of derivations per event, like the UI does) .
+ * bun scripts/bench-pure.ts <conversationId> [scale]
+ * `scale` synthesizes a longer turn by repeating the replay's step events.
+ */
+import {
+ applyHistory,
+ foldEvent,
+ groupRenderedChunks,
+ initialState,
+ initialWindowSize,
+ selectChunks,
+ windowTranscript,
+} from "../src/core/chunks/index.ts";
+import { interleaveTurnMetrics } from "../src/core/metrics/index.ts";
+import { renderMarkdown } from "../src/features/markdown/logic/markdown.ts";
+
+const conv = process.argv[2] ?? "";
+const HTTP = process.env.PROBE_HTTP ?? "http://localhost:24991";
+const replay = JSON.parse(await Bun.file(`/tmp/replay-${conv}.json`).text()) as { type: string }[];
+const hist = (await (await fetch(`${HTTP}/conversations/${conv}?sinceSeq=0`)).json()) as {
+ chunks: { seq: number; turnId?: string; chunk: { type: string } }[];
+};
+const turnId = (replay.find((e) => e.type === "turn-start") as { turnId: string }).turnId;
+const committedThisTurn = hist.chunks.filter((c) => c.turnId === turnId).length;
+console.log(
+ `history chunks=${hist.chunks.length} (of which current turn=${committedThisTurn}), replay events=${replay.length}`,
+);
+
+let state = windowTranscript(
+ applyHistory(initialState(), hist.chunks as never),
+ initialWindowSize(256),
+);
+let derive = 0;
+let md = 0;
+const t0 = performance.now();
+for (const ev of replay) {
+ state = foldEvent(state, ev as never);
+ // What each per-event render recomputes in ChatView:
+ const d0 = performance.now();
+ const chunks = selectChunks(state);
+ interleaveTurnMetrics(groupRenderedChunks(chunks), []);
+ derive += performance.now() - d0;
+ const last = chunks[chunks.length - 1];
+ if (last?.streaming && last.chunk.type === "text") {
+ const m0 = performance.now();
+ renderMarkdown(last.chunk.text, { streaming: true });
+ md += performance.now() - m0;
+ }
+}
+const total = performance.now() - t0;
+const rendered = selectChunks(state);
+const dupTool = new Map<string, number>();
+for (const r of rendered) {
+ if (r.chunk.type === "tool-call" || r.chunk.type === "tool-result") {
+ const k = `${r.chunk.type}:${(r.chunk as { toolCallId: string }).toolCallId}`;
+ dupTool.set(k, (dupTool.get(k) ?? 0) + 1);
+ }
+}
+const dups = [...dupTool.values()].filter((n) => n > 1).length;
+console.log({
+ renderedChunks: rendered.length,
+ committed: state.committed.length,
+ provisional: state.provisional.length,
+ duplicatedToolChunks: dups,
+ totalMs: Math.round(total),
+ deriveMs: Math.round(derive),
+ markdownFullRerenderMs: Math.round(md),
+});
diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts
index 952163c..d7b8c6b 100644
--- a/scripts/live-probe-provider-retry.ts
+++ b/scripts/live-probe-provider-retry.ts
@@ -19,7 +19,7 @@
* the JSON-parse boundary the unit tests skip (they pass constructed events).
*
* bun scripts/live-probe-provider-retry.ts
- * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts
+ * PROBE_MODEL=opencode-1/glm-5.2 bun scripts/live-probe-provider-retry.ts
*/
import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
@@ -33,7 +33,7 @@ import {
} from "../src/core/chunks/index.ts";
const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
-const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
+const MODEL = process.env.PROBE_MODEL ?? "opencode-1/deepseek-v4-flash";
const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok";
type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts
index 6121eac..ad00179 100644
--- a/scripts/live-probe.ts
+++ b/scripts/live-probe.ts
@@ -4,7 +4,7 @@
* `bun run test`. Run with the backend up:
*
* bun scripts/live-probe.ts # default model
- * PROBE_MODEL=opencode/glm-5 bun scripts/live-probe.ts
+ * PROBE_MODEL=opencode-1/glm-5 bun scripts/live-probe.ts
* PROBE_TOOL_PROMPT="..." bun scripts/live-probe.ts # override the tool turn
*
* Drives the FE's REAL network-facing modules (the thin live integration test the
@@ -55,7 +55,7 @@ import { createConversationCache } from "../src/features/conversation-cache/inde
const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203";
-const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
+const MODEL = process.env.PROBE_MODEL ?? "opencode-1/deepseek-v4-flash";
const TEXT_PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch";
const TOOL_PROMPT =
process.env.PROBE_TOOL_PROMPT ??
diff --git a/scripts/network-policy.test.ts b/scripts/network-policy.test.ts
new file mode 100644
index 0000000..4c3ed12
--- /dev/null
+++ b/scripts/network-policy.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildAllowedHosts,
+ checkDevRequest,
+ checkHost,
+ isAllowedLocalAddress,
+ normalizeHost,
+ normalizeHostHeader,
+ tailscaleSelf,
+} from "./network-policy";
+
+// Synthetic status: reserved .example DNS and documentation-only IPv4/IPv6 addresses.
+const tailscaleStatusJson = `{"Self":{"HostName":"laptop",
+ "DNSName":"dev-node.tailnet.example.",
+ "TailscaleIPs":["192.0.2.10","2001:db8::10"]}}`;
+const allowedHosts = buildAllowedHosts({ tailscaleStatusJson, env: {} });
+const allowedAddresses = new Set(["127.0.0.1", "::1", ...tailscaleSelf(tailscaleStatusJson).ips]);
+const policy = { allowedHosts, allowedAddresses };
+const request = (localAddress = "::1", host: string | undefined = "localhost", origin?: string) =>
+ checkDevRequest({ localAddress, host, origin }, policy);
+
+describe("backend-compatible hosts", () => {
+ it.each([
+ ["LOCALHOST.:34204", "localhost"],
+ ["127.0.0.1:34204", "127.0.0.1"],
+ ["[0:0:0:0:0:0:0:1]:34204", "::1"],
+ ["DEV-NODE", "dev-node"],
+ ["DEV-NODE.tailnet.example.:34204", "dev-node.tailnet.example"],
+ ["192.0.2.10:34204", "192.0.2.10"],
+ ["[2001:db8::10]:34204", "2001:db8::10"],
+ ])("normalizes and allows %s", (raw, expected) => {
+ expect(normalizeHost(raw)).toBe(expected);
+ expect(checkHost(raw, allowedHosts)).toBe("ok");
+ });
+
+ it.each(
+ (
+ "evil.com|dev-node.evil.com|192.0.2.10.nip.io|laptop|localhost.evil.com|" +
+ "sub.localhost|127.0.0.2|100.1.2.3|172.17.0.1|[email protected]|localhost/path|" +
+ "localhost:bad|localhost:65536|[::1]suffix|localhost\n|localhost..|%6cocalhost|"
+ ).split("|"),
+ )("rejects %j even where Vite would allow it", (host) => {
+ expect(checkHost(host, allowedHosts)).toMatchObject({ status: 421 });
+ });
+
+ const missingStatuses = [undefined, "invalid", "null", "[]", "{}", '{"Self":null}'];
+ it.each(missingStatuses)("fails closed for status %j, retains env overrides", (json) => {
+ const input = {
+ ...(json !== undefined ? { tailscaleStatusJson: json } : {}),
+ env: {
+ DISPATCH_ALLOWED_HOSTS: " EXTRA.example.:8080, https://evil.com",
+ DISPATCH_TAILSCALE_NAME: "DEV-NODE",
+ },
+ };
+ const expected = "localhost,127.0.0.1,::1,extra.example,dev-node".split(",");
+ expect([...buildAllowedHosts(input)]).toEqual(expected);
+ expect(tailscaleSelf(json)).toEqual({ ips: [] });
+ });
+
+ it("keeps auto-detection when env hosts are supplied, without trusting OS hostname", () => {
+ const hosts = buildAllowedHosts({
+ tailscaleStatusJson,
+ env: { DISPATCH_ALLOWED_HOSTS: "extra.example" },
+ });
+ expect(hosts.has("dev-node.tailnet.example")).toBe(true);
+ expect(hosts.has("extra.example")).toBe(true);
+ expect(hosts.has("laptop")).toBe(false);
+ expect(tailscaleSelf(tailscaleStatusJson).hostName).toBe("laptop");
+ expect(tailscaleSelf('{"Self":{"TailscaleIPs":[null,"evil.com","192.0.2.10"]}}').ips).toEqual([
+ "192.0.2.10",
+ ]);
+ });
+
+ it.each([
+ ["LOCALHOST.:34204", "localhost:34204"],
+ ["[0:0:0:0:0:0:0:1]:34204", "[::1]:34204"],
+ ["[::1]", "[::1]"],
+ ["DEV-NODE.", "dev-node"],
+ ])("preserves valid authorities for Vite: %s", (raw, expected) => {
+ expect(normalizeHostHeader(raw)).toBe(expected);
+ });
+});
+
+describe("destination interfaces (not remote peer addresses)", () => {
+ it.each(
+ (
+ "127.0.0.1|::1|0:0:0:0:0:0:0:1|::ffff:127.0.0.1|::ffff:7f00:1|" +
+ "192.0.2.10|::ffff:192.0.2.10|2001:db8::10"
+ ).split("|"),
+ )("allows local address %s", (address) => {
+ expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(true);
+ });
+
+ it.each([
+ undefined,
+ ..."|172.17.0.1|::ffff:172.17.0.1|192.168.1.2|100.1.2.3|0.0.0.0|::|127.0.0.2".split("|"),
+ ])("rejects non-selected interface %j", (address) => {
+ expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(false);
+ // undefined must not use the helper's default loopback address.
+ expect(
+ checkDevRequest({ localAddress: address, host: "localhost", origin: undefined }, policy),
+ ).toMatchObject({ status: 403 });
+ });
+
+ it("does not treat an env host extra as an allowed interface", () => {
+ const hosts = buildAllowedHosts({ env: { DISPATCH_ALLOWED_HOSTS: "172.17.0.1" } });
+ expect(
+ checkDevRequest(
+ { localAddress: "172.17.0.1", host: "172.17.0.1", origin: undefined },
+ { allowedHosts: hosts, allowedAddresses },
+ ),
+ ).toMatchObject({ status: 403 });
+ });
+});
+
+describe("shared HTTP / HMR request decision", () => {
+ it.each([
+ undefined,
+ "http://DEV-NODE.:34204",
+ "https://[2001:db8::10]:443",
+ ])("allows trusted or absent Origin %j", (origin) => {
+ expect(request("::ffff:192.0.2.10", "dev-node", origin)).toBe("ok");
+ });
+ const badOrigins =
+ "null|file://localhost|https://evil.com|http://localhost/|http://user@localhost";
+ it.each(badOrigins.split("|"))("rejects Origin %j", (origin) => {
+ expect(request("::1", "localhost", origin)).toMatchObject({ status: 403 });
+ });
+ it("requires Host and checks it before Origin", () => {
+ for (const host of [undefined, "evil.com"]) {
+ expect(
+ checkDevRequest({ localAddress: "127.0.0.1", host, origin: "null" }, policy),
+ ).toMatchObject({ status: 421 });
+ }
+ });
+});
diff --git a/scripts/network-policy.ts b/scripts/network-policy.ts
new file mode 100644
index 0000000..3bb122c
--- /dev/null
+++ b/scripts/network-policy.ts
@@ -0,0 +1,165 @@
+/** Host/Origin semantics mirrored from backend packages/network-policy (review step 2.4).
+ * Keep in sync there; no I/O or ambient environment reads in this module.
+ */
+export interface NetworkPolicyEnv {
+ readonly DISPATCH_ALLOWED_HOSTS?: string;
+ readonly DISPATCH_TAILSCALE_NAME?: string;
+}
+
+export type PolicyDecision<Status extends number> = "ok" | { status: Status; reason: string };
+type HeaderValue = string | null | undefined;
+
+function ipAddress(raw: string): string {
+ if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(raw)) {
+ return raw.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part)
+ ? raw
+ : "";
+ }
+ if (!raw.includes(":") || /[^\da-f:.]/i.test(raw)) return "";
+ try {
+ return new URL(`http://[${raw}]`).hostname.slice(1, -1);
+ } catch {
+ return "";
+ }
+}
+
+/** Invalid authorities normalize to "" (fail closed). Bare IPv6 is also accepted. */
+export function normalizeHost(raw: string): string {
+ const host = raw.toLowerCase();
+ if (host.includes(":") && !host.startsWith("[") && host.split(":").length > 2) {
+ return ipAddress(host);
+ }
+ const match = /^(?:\[([\da-f:.]+)\]|([^\s:/\\@?#[\],%]+))(?::(\d+))?$/.exec(host);
+ if (!match || match[0] !== host || (match[3] !== undefined && Number(match[3]) > 65535)) {
+ return "";
+ }
+ if (match[1]) return match[1].includes(":") ? ipAddress(match[1]) : "";
+ const name = (match[2] ?? "").replace(/\.$/, "");
+ return name.length <= 253 &&
+ name.split(".").every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/.test(label))
+ ? name
+ : "";
+}
+
+/** OS HostName is deliberately not trusted; the shell may add it after verifying DNS. */
+export function buildAllowedHosts(input: {
+ tailscaleStatusJson?: string;
+ env: NetworkPolicyEnv;
+}): ReadonlySet<string> {
+ const allowed = new Set(["localhost", "127.0.0.1", "::1"]);
+ const add = (raw: string) => {
+ const normalized = normalizeHost(raw);
+ if (normalized) allowed.add(normalized);
+ };
+ try {
+ const status: unknown = JSON.parse(input.tailscaleStatusJson ?? "null");
+ if (status && typeof status === "object" && "Self" in status) {
+ const self = status.Self;
+ if (self && typeof self === "object") {
+ if ("DNSName" in self && typeof self.DNSName === "string") {
+ const name = normalizeHost(self.DNSName);
+ if (name) {
+ add(name);
+ add(name.split(".")[0] ?? "");
+ }
+ }
+ if ("TailscaleIPs" in self && Array.isArray(self.TailscaleIPs)) {
+ for (const ip of self.TailscaleIPs) {
+ if (typeof ip === "string" && ipAddress(ip)) add(ip);
+ }
+ }
+ }
+ }
+ } catch {
+ // Missing/malformed status must leave the loopback and explicit env policy intact.
+ }
+ for (const host of (input.env.DISPATCH_ALLOWED_HOSTS ?? "").split(",")) add(host.trim());
+ if (input.env.DISPATCH_TAILSCALE_NAME) add(input.env.DISPATCH_TAILSCALE_NAME.trim());
+ return allowed;
+}
+
+export function checkHost(
+ hostHeader: HeaderValue,
+ allowed: ReadonlySet<string>,
+): PolicyDecision<421> {
+ const host = hostHeader == null ? "" : normalizeHost(hostHeader);
+ return host && allowed.has(host) ? "ok" : { status: 421, reason: "Host is not allowed" };
+}
+
+export function checkOrigin(
+ originHeader: HeaderValue,
+ allowed: ReadonlySet<string>,
+): PolicyDecision<403> {
+ if (originHeader == null) return "ok";
+ const match = /^https?:\/\/([^/\\?#\s]+)$/i.exec(originHeader);
+ if (match?.[1] && match[0] === originHeader && checkHost(match[1], allowed) === "ok") {
+ try {
+ new URL(originHeader);
+ return "ok";
+ } catch {
+ // Reject malformed URL authorities even if they resemble an allowed hostname.
+ }
+ }
+ return { status: 403, reason: "Origin is not allowed" };
+}
+
+/** Canonical authority for Vite's narrower parser; preserve ports and IPv6 brackets. */
+export function normalizeHostHeader(raw: string): string {
+ const host = normalizeHost(raw);
+ const port = /^(?:\[[^\]]+\]|[^:]+)(:\d+)$/.exec(raw)?.[1] ?? "";
+ return `${host.includes(":") ? `[${host}]` : host}${port}`;
+}
+
+/** Only the destination interface matters, NOT the remote peer's IP. */
+export function isAllowedLocalAddress(
+ address: string | undefined,
+ allowedAddresses: ReadonlySet<string>,
+): boolean {
+ if (!address) return false;
+ // Dual-stack Node listeners report IPv4 destinations as ::ffff:a.b.c.d (or hex).
+ let normalized = normalizeHost(address);
+ if (normalized.startsWith("::ffff:")) {
+ const words = normalized.slice(7).split(":");
+ if (words.length === 2) {
+ const value =
+ Number.parseInt(words[0] ?? "", 16) * 65536 + Number.parseInt(words[1] ?? "", 16);
+ normalized = [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join(".");
+ }
+ }
+ return allowedAddresses.has(normalized);
+}
+
+/** Extract only actual IPs; neither env host extras nor a whole Tailscale subnet is trusted. */
+export function tailscaleSelf(json: string | undefined): { ips: string[]; hostName?: string } {
+ try {
+ const self = JSON.parse(json ?? "null")?.Self;
+ const ips = Array.isArray(self?.TailscaleIPs)
+ ? self.TailscaleIPs.filter(
+ (ip: unknown): ip is string => typeof ip === "string" && !!ipAddress(ip),
+ )
+ : [];
+ return {
+ ips: ips.map((ip: string) => ipAddress(ip)),
+ ...(typeof self?.HostName === "string" ? { hostName: self.HostName } : {}),
+ };
+ } catch {
+ return { ips: [] };
+ }
+}
+
+export interface DevNetworkPolicy {
+ readonly allowedHosts: ReadonlySet<string>;
+ readonly allowedAddresses: ReadonlySet<string>;
+}
+
+/** Shared HTTP and HMR guard. Host precedes Origin, matching the backend. */
+export function checkDevRequest(
+ input: { localAddress: string | undefined; host: string | undefined; origin: string | undefined },
+ policy: DevNetworkPolicy,
+): PolicyDecision<403 | 421> {
+ if (!isAllowedLocalAddress(input.localAddress, policy.allowedAddresses)) {
+ return { status: 403, reason: "Local interface is not allowed" };
+ }
+ const host = checkHost(input.host, policy.allowedHosts);
+ return host === "ok" ? checkOrigin(input.origin, policy.allowedHosts) : host;
+}
diff --git a/scripts/probe-replay.ts b/scripts/probe-replay.ts
new file mode 100644
index 0000000..8a7faf9
--- /dev/null
+++ b/scripts/probe-replay.ts
@@ -0,0 +1,36 @@
+/**
+ * Probe: subscribe to a conversation over WS, capture the late-join replay,
+ * and report event counts / bytes / time-to-receive. Saves to /tmp/replay-<id>.json.
+ * bun scripts/probe-replay.ts <conversationId> [wsUrl]
+ */
+const conv = process.argv[2];
+const url = process.argv[3] ?? "ws://localhost:24990";
+if (!conv) throw new Error("conversation id required");
+
+const ws = new WebSocket(url);
+const events: unknown[] = [];
+let bytes = 0;
+let t0 = 0;
+let lastAt = 0;
+ws.onopen = () => {
+ t0 = performance.now();
+ ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: conv }));
+};
+ws.onmessage = (ev) => {
+ const s = String(ev.data);
+ const m = JSON.parse(s);
+ if (m.type !== "chat.delta") return;
+ bytes += s.length;
+ lastAt = performance.now();
+ events.push(m.event);
+};
+setTimeout(
+ async () => {
+ ws.close();
+ const counts: Record<string, number> = {};
+ for (const e of events as { type: string }[]) counts[e.type] = (counts[e.type] ?? 0) + 1;
+ console.log({ events: events.length, bytes, recvMs: Math.round(lastAt - t0), counts });
+ await Bun.write(`/tmp/replay-${conv}.json`, JSON.stringify(events));
+ },
+ Number(process.env.WAIT_MS ?? 3000),
+);
diff --git a/scripts/probe-tab.ts b/scripts/probe-tab.ts
new file mode 100644
index 0000000..16ad423
--- /dev/null
+++ b/scripts/probe-tab.ts
@@ -0,0 +1,113 @@
+/**
+ * Focused probe: load a selected conversation through the FE's REAL pure modules
+ * and see whether the tab's render path would throw. Not part of the test suite.
+ *
+ * bun scripts/probe-tab.ts <conversationId>
+ */
+import "fake-indexeddb/auto";
+import type {
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
+import {
+ applyHistory,
+ groupRenderedChunks,
+ initialState,
+ selectChunks,
+ selectMessages,
+} from "../src/core/chunks/index.ts";
+import {
+ applyDurableMetrics,
+ initialMetricsState,
+ selectOrderedTurnMetrics,
+} from "../src/core/metrics/index.ts";
+
+const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24991";
+const CONV = process.argv[2];
+if (!CONV) {
+ console.error("Usage: bun scripts/probe-tab.ts <conversationId>");
+ process.exit(1);
+}
+
+async function historySync(id: string, sinceSeq: number): Promise<ConversationHistoryResponse> {
+ const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`;
+ const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
+ if (!res.ok) throw new Error(`history fetch ${res.status} for ${url}`);
+ return (await res.json()) as ConversationHistoryResponse;
+}
+
+async function metricsSync(id: string): Promise<ConversationMetricsResponse> {
+ const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`, {
+ headers: { Origin: "http://localhost:24204" },
+ });
+ if (!res.ok) throw new Error(`metrics fetch ${res.status}`);
+ return (await res.json()) as ConversationMetricsResponse;
+}
+
+let failures = 0;
+const record = (name: string, ok: boolean, detail?: string) => {
+ if (!ok) failures += 1;
+ console.log(` ${ok ? "OK" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
+};
+
+console.log(`Probing conversation ${CONV} via ${HTTP_BASE}`);
+
+// 1. applyHistory (the tab's cold-load path)
+try {
+ const hist = await historySync(CONV, 0);
+ const chunks = hist.chunks ?? [];
+ console.log(` history: ${chunks.length} chunks, latestSeq=${hist.latestSeq}`);
+ let state = initialState();
+ try {
+ state = applyHistory(state, chunks as never);
+ record(
+ `applyHistory(${chunks.length} chunks)`,
+ true,
+ `state=${JSON.stringify(state).length} bytes`,
+ );
+ } catch (e) {
+ record("applyHistory", false, String(e));
+ }
+
+ // 2. selectMessages / selectChunks — what the transcript renders
+ try {
+ const messages = selectMessages(state);
+ record("selectMessages", true, `${messages.length} messages`);
+ } catch (e) {
+ record("selectMessages", false, String(e));
+ }
+
+ // 3. groupRenderedChunks — the render grouping
+ try {
+ const grouped = groupRenderedChunks(selectChunks(state));
+ record("groupRenderedChunks", true, `${grouped.length} groups`);
+ } catch (e) {
+ record("groupRenderedChunks", false, String(e));
+ }
+} catch (e) {
+ record("history load", false, String(e));
+}
+
+// 4. metrics path
+try {
+ const m = await metricsSync(CONV);
+ let ms = initialMetricsState();
+ const turns = (m as { turns?: unknown[] }).turns ?? [];
+ try {
+ ms = applyDurableMetrics(ms, turns as never);
+ } catch (e) {
+ failures += 1;
+ console.log(` FAIL applyDurableMetrics — ${String(e)}`);
+ }
+ const ordered = selectOrderedTurnMetrics(ms);
+ record(
+ "metrics fold+apply",
+ true,
+ `${ordered.length} ordered turn metrics (${turns.length} turns)`,
+ );
+} catch (e) {
+ record("metrics load", false, String(e));
+}
+
+console.log(failures === 0 ? "\nALL OK — tab render path is clean." : `\n${failures} FAILURE(S).`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/scripts/text-file-environment.ts b/scripts/text-file-environment.ts
new file mode 100644
index 0000000..6507be2
--- /dev/null
+++ b/scripts/text-file-environment.ts
@@ -0,0 +1,19 @@
+import type { Environment } from "vitest/environments";
+import { builtinEnvironments } from "vitest/environments";
+
+/** Real runtime fetch and AbortSignal must belong to the same implementation.
+ * jsdom supplies DOM APIs but replaces these globals with an incompatible realm.
+ * Keep the runtime's pair for this opt-in real-network component probe only.
+ */
+export default {
+ name: "text-file-jsdom",
+ transformMode: "web",
+ async setup(global, options) {
+ const controller = global.AbortController;
+ const signal = global.AbortSignal;
+ const environment = await builtinEnvironments.jsdom.setup(global, options);
+ global.AbortController = controller;
+ global.AbortSignal = signal;
+ return environment;
+ },
+} satisfies Environment;
diff --git a/scripts/text-file-live.test.ts b/scripts/text-file-live.test.ts
new file mode 100644
index 0000000..3305616
--- /dev/null
+++ b/scripts/text-file-live.test.ts
@@ -0,0 +1,119 @@
+import { type ChildProcess, spawn } from "node:child_process";
+import { rm, writeFile } from "node:fs/promises";
+import { resolve as resolvePath } from "node:path";
+import { createInterface } from "node:readline";
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { createTextFileClient, Markdown } from "../src/features/markdown";
+
+interface Fixture {
+ url: string;
+ path: string;
+ cwd: string;
+ conversationId: string;
+ remoteConversationId: string;
+}
+
+// Opt-in real cross-repo UI/network probe. Owns an ephemeral loopback server and
+// temporary files only; never connects to or restarts the deployed application.
+// bun run test:text-file-live
+// The backend must be installed and typechecked first (workspace public dists).
+describe.skipIf(process.env.TEXT_FILE_LIVE_PROBE !== "1")("live saved-file chat display", () => {
+ let child: ChildProcess;
+ let fixture: Fixture;
+ beforeAll(async () => {
+ fixture = await new Promise<Fixture>((resolve, reject) => {
+ child = spawn("bun", ["packages/transport-http/src/text-file-probe.ts"], {
+ cwd: resolvePath(process.cwd(), "../backend"),
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ const timer = setTimeout(
+ () => reject(new Error("Text-file fixture startup timed out")),
+ 15000,
+ );
+ let stderr = "";
+ child.stderr?.on("data", (chunk) => {
+ stderr += String(chunk);
+ });
+ child.once("error", reject);
+ child.once("exit", (code) => {
+ clearTimeout(timer);
+ reject(new Error(`Text-file fixture exited (${code}): ${stderr}`));
+ });
+ if (!child.stdout) {
+ reject(new Error("Missing fixture stdout"));
+ return;
+ }
+ const lines = createInterface({ input: child.stdout });
+ lines.once("line", (line) => {
+ clearTimeout(timer);
+ try {
+ resolve(JSON.parse(line) as Fixture);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+ }, 20000);
+ afterAll(async () => {
+ if (child && child.exitCode === null) {
+ await new Promise<void>((resolve) => {
+ const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
+ child.once("exit", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ child.kill("SIGTERM");
+ });
+ }
+ });
+
+ it("renders only the saved reference, previews/expands Markdown, and rereads changed/deleted files on refresh and remount", async () => {
+ const saved = Array.from({ length: 20 }, (_, i) => `## Saved step ${i + 1}`).join("\n");
+ await writeFile(fixture.path, saved);
+ const reply = "I saved the implementation plan.\n\n[Plan](text-file:plan%20100%25.md)";
+ const client = createTextFileClient(fixture.url, fixture.conversationId, fetch);
+ let view = render(Markdown, { text: reply, textFiles: client });
+ try {
+ await screen.findByRole("heading", { name: "Saved step 1" });
+ expect(screen.getByRole("heading", { name: "Saved step 20" })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Saved step 10" })).not.toBeInTheDocument();
+ await fireEvent.click(screen.getByRole("button", { name: "Expand document" }));
+ expect(screen.getByRole("heading", { name: "Saved step 10" })).toBeInTheDocument();
+ expect(reply).not.toContain(saved);
+ await writeFile(fixture.path, "# Updated on disk\n\n**Fresh content**");
+ await fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
+ await screen.findByRole("heading", { name: "Updated on disk" });
+ expect(view.container.querySelector("strong")?.textContent).toBe("Fresh content");
+ view.unmount();
+ view = render(Markdown, { text: reply, textFiles: client });
+ await screen.findByRole("heading", { name: "Updated on disk" });
+ await rm(fixture.path);
+ await fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Text file not found");
+ await writeFile(fixture.path, "# Recreated plan");
+ await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ await screen.findByRole("heading", { name: "Recreated plan" });
+ } finally {
+ view.unmount();
+ client.dispose();
+ }
+ });
+
+ it("does not read the same local path for an authoritative remote conversation", async () => {
+ const client = createTextFileClient(fixture.url, fixture.remoteConversationId, fetch);
+ const view = render(Markdown, {
+ text: `[Remote](text-file:${encodeURI(fixture.path)})`,
+ textFiles: client,
+ });
+ try {
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "not available for remote computers",
+ );
+ expect(screen.queryByRole("heading", { name: "Recreated plan" })).not.toBeInTheDocument();
+ } finally {
+ view.unmount();
+ client.dispose();
+ }
+ });
+});
diff --git a/scripts/vite-network-guard.test.ts b/scripts/vite-network-guard.test.ts
new file mode 100644
index 0000000..8bef397
--- /dev/null
+++ b/scripts/vite-network-guard.test.ts
@@ -0,0 +1,117 @@
+import { EventEmitter } from "node:events";
+import type { IncomingMessage, ServerResponse } from "node:http";
+import type { Connect, Plugin, PreviewServer, ViteDevServer } from "vite";
+import { describe, expect, it, vi } from "vitest";
+import { applyViteNetworkPolicy, devNetworkGuard } from "./vite-network-guard";
+
+const policy = {
+ allowedHosts: new Set([
+ "localhost",
+ "127.0.0.1",
+ "::1",
+ "dev-node.tailnet.example",
+ "192.0.2.10",
+ ]),
+ allowedAddresses: new Set(["127.0.0.1", "::1", "192.0.2.10"]),
+};
+
+function install(mode: "dev" | "preview") {
+ let middleware: Connect.NextHandleFunction | undefined;
+ const http = new EventEmitter();
+ const upgrade = vi.fn();
+ http.on("upgrade", upgrade);
+ const server = {
+ middlewares: {
+ use: (handler: Connect.NextHandleFunction) => {
+ middleware = handler;
+ },
+ },
+ httpServer: http,
+ };
+ const plugin = devNetworkGuard(policy);
+ const hook = mode === "dev" ? plugin.configureServer : plugin.configurePreviewServer;
+ if (typeof hook !== "function") throw new Error(`Missing ${mode} guard hook`);
+ // Hooks only need the injected middleware stack and HTTP event emitter.
+ hook.call({} as never, server as unknown as ViteDevServer & PreviewServer);
+ if (!middleware) throw new Error(`Missing ${mode} middleware`);
+ return { middleware, http, upgrade };
+}
+
+function request(localAddress: string | undefined, host?: string, origin?: string) {
+ return { socket: { localAddress }, headers: { host, origin } } as IncomingMessage;
+}
+
+const cases = [
+ ["loopback", "127.0.0.1", "localhost", undefined, 200],
+ ["IPv6 loopback", "::1", "[::1]:34204", undefined, 200],
+ ["mapped address / canonical Host", "::ffff:127.0.0.1", "LOCALHOST.:34204", undefined, 200],
+ [
+ "tailnet",
+ "192.0.2.10",
+ "dev-node.tailnet.example",
+ "https://dev-node.tailnet.example:34204",
+ 200,
+ ],
+ ["unselected interface with trusted Host", "172.17.0.1", "localhost", undefined, 403],
+ ["missing interface", undefined, "localhost", undefined, 403],
+ ["arbitrary IP Host", "127.0.0.1", "127.0.0.2", undefined, 421],
+ ["rebinding Host", "127.0.0.1", "evil.example", undefined, 421],
+ ["missing Host", "127.0.0.1", undefined, undefined, 421],
+ ["disallowed Origin", "127.0.0.1", "localhost", "https://evil.example", 403],
+ ["opaque Origin", "127.0.0.1", "localhost", "null", 403],
+ ["Host before Origin", "127.0.0.1", "evil.example", "null", 421],
+] as const;
+
+describe.each(["dev", "preview"] as const)("%s HTTP guard", (mode) => {
+ it.each(cases)("%s", (_label, local, host, origin, status) => {
+ const { middleware } = install(mode);
+ const req = request(local, host, origin);
+ const writeHead = vi.fn();
+ const end = vi.fn();
+ const next = vi.fn();
+ middleware(req, { writeHead, end } as unknown as ServerResponse, next);
+ if (status === 200) {
+ expect(next).toHaveBeenCalledExactlyOnceWith();
+ expect(writeHead).not.toHaveBeenCalled();
+ if (host === "LOCALHOST.:34204") expect(req.headers.host).toBe("localhost:34204");
+ } else {
+ expect(writeHead).toHaveBeenCalledWith(status, { "Content-Type": "text/plain" });
+ expect(end).toHaveBeenCalledOnce();
+ expect(next).not.toHaveBeenCalled(); // In particular, cannot reach CORS/static serving.
+ }
+ });
+});
+
+describe("dev upgrades remain protected", () => {
+ it.each(cases)("%s", (_label, local, host, origin, status) => {
+ const { http, upgrade } = install("dev");
+ const end = vi.fn();
+ http.emit("upgrade", request(local, host, origin), { end }, Buffer.alloc(0));
+ if (status === 200) {
+ expect(upgrade).toHaveBeenCalledOnce();
+ expect(end).not.toHaveBeenCalled();
+ } else {
+ expect(upgrade).not.toHaveBeenCalled();
+ expect(end).toHaveBeenCalledWith(expect.stringContaining(`HTTP/1.1 ${status}`));
+ }
+ });
+});
+
+it("sets explicit allowlists and guarded CORS for preview as well as dev", () => {
+ const config = applyViteNetworkPolicy(
+ {
+ server: { host: true, port: 34204 },
+ preview: { host: true, port: 34204, cors: true, allowedHosts: true },
+ },
+ policy,
+ );
+ for (const serving of [config.server, config.preview]) {
+ expect(serving?.allowedHosts).toEqual([...policy.allowedHosts]);
+ expect(serving?.cors).toEqual({ origin: true });
+ expect(serving?.host).toBe(true);
+ expect(serving?.port).toBe(34204);
+ }
+ const plugin = config.plugins?.[0] as Plugin;
+ expect(plugin.configureServer).toBeTypeOf("function");
+ expect(plugin.configurePreviewServer).toBeTypeOf("function");
+});
diff --git a/scripts/vite-network-guard.ts b/scripts/vite-network-guard.ts
new file mode 100644
index 0000000..7fe9eca
--- /dev/null
+++ b/scripts/vite-network-guard.ts
@@ -0,0 +1,74 @@
+import type { IncomingMessage } from "node:http";
+import type { Plugin, UserConfig } from "vite";
+import { checkDevRequest, type DevNetworkPolicy, normalizeHostHeader } from "./network-policy";
+
+/** Explicitly protect BOTH serving modes; preview must not inherit permissive CORS alone. */
+export function applyViteNetworkPolicy(config: UserConfig, network: DevNetworkPolicy): UserConfig {
+ return {
+ ...config,
+ plugins: [devNetworkGuard(network), ...(config.plugins ?? [])],
+ server: {
+ ...config.server,
+ allowedHosts: [...network.allowedHosts],
+ // Safe reflection only after our pre-middleware validates the Origin.
+ cors: { origin: true },
+ },
+ preview: {
+ ...config.preview,
+ allowedHosts: [...network.allowedHosts],
+ cors: { origin: true },
+ },
+ };
+}
+
+export function devNetworkGuard(policy: DevNetworkPolicy): Plugin {
+ const check = (req: IncomingMessage) =>
+ checkDevRequest(
+ {
+ localAddress: req.socket.localAddress,
+ host: req.headers.host,
+ origin: req.headers.origin,
+ },
+ policy,
+ );
+ // Installed before Vite's CORS, host check and static-file middleware in
+ // either mode. No post-hook: even preflights and error responses are guarded.
+ const middleware: import("vite").Connect.NextHandleFunction = (req, res, next) => {
+ const decision = check(req);
+ if (decision !== "ok") {
+ res.writeHead(decision.status, { "Content-Type": "text/plain" });
+ res.end(decision.reason);
+ return;
+ }
+ req.headers.host = normalizeHostHeader(req.headers.host ?? "");
+ next();
+ };
+ return {
+ name: "dispatch-dev-network-guard",
+ enforce: "pre",
+ configurePreviewServer(server) {
+ server.middlewares.use(middleware);
+ },
+ configureServer(server) {
+ server.middlewares.use(middleware);
+ // Vite registers HMR before configureServer. Wrap (rather than prepend to)
+ // its listeners: EventEmitter otherwise continues into HMR after a denial.
+ const http = server.httpServer;
+ if (http) {
+ const upgrades = http.listeners("upgrade");
+ http.removeAllListeners("upgrade");
+ http.on("upgrade", (req, socket, head) => {
+ const decision = check(req);
+ if (decision !== "ok") {
+ socket.end(
+ `HTTP/1.1 ${decision.status} ${decision.status === 421 ? "Misdirected Request" : "Forbidden"}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
+ );
+ return;
+ }
+ req.headers.host = normalizeHostHeader(req.headers.host ?? "");
+ for (const upgrade of upgrades) upgrade.call(http, req, socket, head);
+ });
+ }
+ },
+ };
+}