diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/bench-pure.ts | 71 | ||||
| -rw-r--r-- | scripts/live-probe-provider-retry.ts | 4 | ||||
| -rw-r--r-- | scripts/live-probe.ts | 4 | ||||
| -rw-r--r-- | scripts/network-policy.test.ts | 136 | ||||
| -rw-r--r-- | scripts/network-policy.ts | 165 | ||||
| -rw-r--r-- | scripts/probe-replay.ts | 36 | ||||
| -rw-r--r-- | scripts/probe-tab.ts | 113 | ||||
| -rw-r--r-- | scripts/text-file-environment.ts | 19 | ||||
| -rw-r--r-- | scripts/text-file-live.test.ts | 119 | ||||
| -rw-r--r-- | scripts/vite-network-guard.test.ts | 117 | ||||
| -rw-r--r-- | scripts/vite-network-guard.ts | 74 |
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); + }); + } + }, + }; +} |
