diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e301fee..7114292 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -81,6 +81,9 @@ jobs: if: steps.scope.outputs.mode == 'full' - run: npm run smoke:ocr if: steps.scope.outputs.mode == 'full' + - name: Real CoMER smoke + if: steps.scope.outputs.mode == 'full' + run: npx tsx scripts/ocr-real-smoke.ts - run: npm run smoke:ai if: steps.scope.outputs.mode == 'full' - run: npm run test:pack diff --git a/NOTICE b/NOTICE index 45ede0e..f3268d0 100644 --- a/NOTICE +++ b/NOTICE @@ -2,3 +2,7 @@ MathScrap Copyright 2026 DNTOF Licensed under the Apache License, Version 2.0. + +The bundled handwritten-formula weights are third-party. See public/models/comer/NOTICE.md +for the CoMER (ECCV 2022) model and the ink-on Apache-2.0 distribution. +MathScrap does not relicense those files. diff --git a/crates/mathcore/Cargo.toml b/crates/mathcore/Cargo.toml index 019dbde..90a7898 100644 --- a/crates/mathcore/Cargo.toml +++ b/crates/mathcore/Cargo.toml @@ -3,7 +3,8 @@ name = "mathcore" version = "0.0.1" edition = "2021" description = "Fast math helpers (parse, sample, solve, geometry inference) for the MathScrap kraft notebook, compiled to WASM." -license = "MIT" +# wasm-pack copies this field into src/wasm/pkg/package.json. Keep both Apache-2.0. +license = "Apache-2.0" [lib] crate-type = ["cdylib", "rlib"] diff --git a/docs/packaged-webview-check.md b/docs/packaged-webview-check.md new file mode 100644 index 0000000..6d5afe3 --- /dev/null +++ b/docs/packaged-webview-check.md @@ -0,0 +1,23 @@ +# 打包后的 WebView + +这一轮没有在打包好的窗口里跑过。Linux 上的 CI 只跑了 `test`。`web`、`android`、`windows` 在 pull request 上是跳过的,不是通过。 + +`src-tauri/tauri.conf.json` 里的 CSP 仍有 `https:`。打包后的页面直接 `fetch` 用户填写的 AI 地址,开发时代理不会打进安装包。`script-src` 保留 `wasm-unsafe-eval`,这是本机 ONNX WASM 要编译所需要的。`'unsafe-eval'` 已经去掉。 + +在 Windows 或 Android 上核对时,用一次本地未签名构建,不要改已经发布的 v0.0.1: + +1. 打开应用,写一个短公式,点「识别」或按 R。公式模型仍应能给出候选。这只说明 ONNX 还在工作。 +2. 在设置里填写一个你自己的 HTTPS 模型地址并测试连接。记下是连上了,还是控制台出现 CSP 拒绝。 +3. 不要把自签证书装进受信任的根,也不要关闭系统的签名检查。 + +# Packaged WebView + +This change did not run inside a packaged window. Linux CI ran `test` only. On a pull request, `web`, `android`, and `windows` are skipped. A skip is not a pass. + +The CSP in `src-tauri/tauri.conf.json` still contains `https:`. The packaged page calls `fetch` on the AI URL the user typed. The dev proxy is not in the installed app. `script-src` keeps `wasm-unsafe-eval` so the local ONNX WASM can compile. `'unsafe-eval'` is already gone. + +To check on Windows or Android, use one local unsigned build. Do not change the published v0.0.1: + +1. Open the app, write a short formula, and press Recognize or R. A candidate means ONNX still runs. It is not a handwriting score. +2. In settings, enter an HTTPS model address you control and test the connection. Record whether it connects or the console shows a CSP refusal. +3. Do not install the self-signed certificate as a trusted root, and do not turn off signature checks. diff --git a/docs/release-check.md b/docs/release-check.md new file mode 100644 index 0000000..3a60171 --- /dev/null +++ b/docs/release-check.md @@ -0,0 +1,39 @@ +# 安装后核对 + +这些说明只在源码分支里。已经发布的 v0.0.1 页面没有改。 + +手写识别只在你点「识别」或按 R 时运行。先看笔迹是不是一行字;一行字不会被送进公式模型。公式模型是本地的 CoMER INT8。自动测试用的是程序画出的折线,不是扫描的手写。空白和很小的噪点不会出字。这次实测的合成结果是:`y = 2`(0.97)、`Y = x`(0.75,模型把 y 读成大写)、`(1)/(2)`(0.97)、`a^2 = b`(0.97)、`\sum`(0.84)、`sigma`(0.78)、最高 `A(2, 2)`(0.95),第二名 `A(2, 2, 2)`(0.76)仍是已知的多余坐标。写入线是 0.62。没有附带可再分发的真人笔迹样本,所以不能把这些结果说成手写准确率。门先看笔迹结构,不认识中文或英文。一行或两行普通笔迹不会送进公式模型。有等号、分式、上标、括号、求和、sigma 或箭头的才是公式。数字是模型置信度,不是准确率。 + +开发时的桥需要 HttpOnly 会话 cookie。伪造 Origin 或 Sec-Fetch-Site 拿不到它。浏览器在打开页面时拿到 cookie。stdio 客户端读一个 0600 文件,不会把内容打印出来。局域网要另设一串足够长的分享口令,并 POST 到 `/__mathscrap/join` 才换到 cookie。太短或占位口令不会打开分享。同一台机器上伪造一次“打开页面”的请求仍可能拿到 cookie,同一用户也能读那个文件。模型文件的 SHA-256 记在 `scripts/ocr-model-pins.json`。缺文件或哈希不对,测试失败,不会算通过。 + +Windows 安装包是本机自签,时间戳是 DigiCert RFC3161。系统会显示未知发布者。不要把这个证书装进受信任的根,也不要关掉系统的签名检查。Android 包是 release,arm64,不是调试包。 + +核对已发布文件时,在你自己的电脑上计算 SHA-256,和发布页上的 `SHA256SUMS` 比较: + +``` +MathScrap-0.0.1-windows-x64-setup.exe +MathScrap-0.0.1-windows-x64.exe +MathScrap-0.0.1-android-arm64-v8a.apk +``` + +Git 标签 `v0.0.1` 的签名可以在本地用随包的公钥检查。签名 UID 是 `DNT_OF`,UID 上没有邮箱。标签对象里的 tagger 邮箱是 `3174600270@qq.com`。这是两个不同的字段。签名有效不等于 GitHub 上显示 Verified。 + +# After you install + +These notes live in the source branch. The published v0.0.1 page was not changed. + +Handwriting recognition runs when you press Recognize or R. A line of writing is not sent to the formula model. The formula model is local CoMER INT8. The automated cases use strokes drawn by the program, not scanned handwriting. Blank ink and tiny noise produce no text. The synthetic readings this run actually produced are `y = 2` (0.97), `Y = x` (0.75; the model capitalized y), `(1)/(2)` (0.97), `a^2 = b` (0.97), `\sum` (0.84), `sigma` (0.78), and top `A(2, 2)` (0.95). A weaker second candidate `A(2, 2, 2)` (0.76) is still the known extra-coordinate miss. The write line is 0.62. There is no redistributable sample of a real person's handwriting here, so these results are not a handwriting accuracy claim. Model SHA-256 values are in `scripts/ocr-model-pins.json`. A missing file or a different hash fails the test. + +The Windows installer is self-signed, with a DigiCert RFC3161 timestamp. The system shows an unknown publisher. Do not install that certificate as a trusted root, and do not turn off signature checks. The Android package is a release build for arm64, not a debug build. + +To check the published files, hash them on your own computer and compare with `SHA256SUMS` on the release page: + +``` +MathScrap-0.0.1-windows-x64-setup.exe +MathScrap-0.0.1-windows-x64.exe +MathScrap-0.0.1-android-arm64-v8a.apk +``` + +The `v0.0.1` tag signature can be checked locally with the published public key. The signing UID is `DNT_OF` and that UID has no email. The tagger email stored on the tag object is `3174600270@qq.com`. Those are different fields. A valid signature is not the same as a GitHub Verified badge. + +The packaged WebView still allows `https:` in `connect-src`, because a configured AI endpoint is fetched by the page itself. `script-src` keeps `wasm-unsafe-eval` for ONNX and drops `'unsafe-eval'`. That CSP line has not been executed inside a packaged WebView in this change, and CORS for a packaged build has not been run. The dev bridge requires an HttpOnly session cookie. Forged `Origin` and `Sec-Fetch-Site` headers do not grant it. The browser receives the cookie on a document load. The stdio client reads a mode 0600 file and does not print it. A LAN peer gets a cookie only by posting the operator's share token to `/__mathscrap/join`; a short or placeholder token does not turn sharing on. A process on the same machine that forges a document navigation can still receive the cookie, and a same-user process can read the session file. diff --git a/mcp/ai-proxy.ts b/mcp/ai-proxy.ts index e80e273..258932c 100644 --- a/mcp/ai-proxy.ts +++ b/mcp/ai-proxy.ts @@ -1,17 +1,31 @@ import { t } from '../src/i18n/locale.ts' +import http from 'node:http' +import https from 'node:https' import type { IncomingMessage, ServerResponse } from 'node:http' import { readBody, sendJson } from './http.ts' +import { isBlockedHostname, localOriginsFromEnv, resolveAndCheck, sameAddressSet, type LookupFn } from './ssrf.ts' +import { peerAllowed } from './peer.ts' export const PROXY_HEADER = 'x-mathscrap-proxy' const ALLOWED_HEADERS = new Set(['authorization', 'content-type', 'accept', 'x-api-key', 'anthropic-version', 'anthropic-dangerous-direct-browser-access']) +export type ForwardDeps = { + lookup?: LookupFn + localOrigins?: URL[] +} + /** * Forwards one provider request from the page. Dev-only. * Never log the body or headers — they may contain the API key. * Only chat/models/messages paths are allowed, and link-local metadata hosts are refused. */ -export async function forwardAi(payload: { url?: unknown; method?: unknown; headers?: unknown; body?: unknown }, fetchImpl: typeof fetch = fetch, signal?: AbortSignal) { +export async function forwardAi( + payload: { url?: unknown; method?: unknown; headers?: unknown; body?: unknown }, + fetchImpl?: typeof fetch, + signal?: AbortSignal, + deps: ForwardDeps = {}, +) { const method = typeof payload.method === 'string' ? payload.method.toUpperCase() : 'GET' if (method !== 'GET' && method !== 'POST') throw new Error(t('proxyMethod')) if (typeof payload.url !== 'string' || !payload.url) throw new Error(t('proxyTarget')) @@ -23,8 +37,14 @@ export async function forwardAi(payload: { url?: unknown; method?: unknown; head } if (target.protocol !== 'http:' && target.protocol !== 'https:') throw new Error(t('proxyHttp')) if (target.username || target.password) throw new Error(t('proxyUserinfo')) - if (isBlockedHost(target.hostname)) throw new Error(t('proxyBlocked')) if (!allowedPath(target.pathname)) throw new Error(t('proxyPath')) + const origins = deps.localOrigins ?? localOriginsFromEnv() + let addresses + try { + addresses = await resolveAndCheck(target, deps.lookup, origins) + } catch { + throw new Error(t('proxyBlocked')) + } const headers: Record = { accept: 'application/json' } if (payload.headers && typeof payload.headers === 'object') { for (const [key, value] of Object.entries(payload.headers as Record)) { @@ -33,16 +53,20 @@ export async function forwardAi(payload: { url?: unknown; method?: unknown; head headers[key] = value } } - const timeout = AbortSignal.timeout(25_000) - const upstream = await fetchImpl(target, { - method, - headers, - body: method === 'POST' ? JSON.stringify(payload.body ?? {}) : undefined, - redirect: 'error', - signal: signal ? AbortSignal.any([timeout, signal]) : timeout, - }) - const text = (await upstream.text()).slice(0, 200_000) - return { status: upstream.status, text, contentType: upstream.headers.get('content-type') ?? 'application/json; charset=utf-8' } + if (fetchImpl) { + const timeout = AbortSignal.timeout(25_000) + const upstream = await fetchImpl(target, { + method, + headers, + body: method === 'POST' ? JSON.stringify(payload.body ?? {}) : undefined, + redirect: 'error', + signal: signal ? AbortSignal.any([timeout, signal]) : timeout, + }) + if (upstream.status >= 300 && upstream.status < 400) throw new Error(t('proxyBlocked')) + const text = (await upstream.text()).slice(0, 200_000) + return { status: upstream.status, text, contentType: upstream.headers.get('content-type') ?? 'application/json; charset=utf-8' } + } + return requestPinned(target, method, headers, method === 'POST' ? JSON.stringify(payload.body ?? {}) : undefined, addresses, signal, deps) } export async function handleAiProxy(req: IncomingMessage, res: ServerResponse, fetchImpl?: typeof fetch) { @@ -51,6 +75,10 @@ export async function handleAiProxy(req: IncomingMessage, res: ServerResponse, f sendJson(res, 404, { error: '未知路径' }, { [PROXY_HEADER]: '1' }) return } + if (!peerAllowed(req, true)) { + sendJson(res, 403, { error: t('proxyBlocked') }, { [PROXY_HEADER]: '1' }) + return + } try { const payload = JSON.parse(await readBody(req, 1_000_000)) as { url?: unknown; method?: unknown; headers?: unknown; body?: unknown } const ac = new AbortController() @@ -74,9 +102,63 @@ export function allowedPath(pathname: string) { } export function isBlockedHost(hostname: string) { - const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') - if (host === 'metadata.google.internal' || host === 'metadata.google.com') return true - if (host.endsWith('.internal') && host.includes('metadata')) return true - const v4 = host.startsWith('::ffff:') ? host.slice(7) : host - return /^169\.254\./.test(v4) + return isBlockedHostname(hostname, false) +} + +function requestPinned( + target: URL, + method: string, + headers: Record, + body: string | undefined, + addresses: { address: string; family: number }[], + signal?: AbortSignal, + deps: ForwardDeps = {}, +) { + const lib = target.protocol === 'https:' ? https : http + const pinned = addresses[0] + return new Promise<{ status: number; text: string; contentType: string }>((resolve, reject) => { + const req = lib.request( + { + protocol: target.protocol, + hostname: pinned.address, + port: target.port || (target.protocol === 'https:' ? 443 : 80), + path: `${target.pathname}${target.search}`, + method, + headers: { ...headers, host: target.host }, + servername: target.hostname, + lookup: (_hostname, _options, callback) => { + resolveAndCheck(target, deps.lookup, deps.localOrigins ?? localOriginsFromEnv()) + .then((again) => { + if (!sameAddressSet(again, addresses)) { + callback(new Error('blocked'), '', 4) + return + } + callback(null, pinned.address, pinned.family) + }) + .catch((error: Error) => callback(error, '', 4)) + }, + }, + (res) => { + if ((res.statusCode ?? 0) >= 300 && (res.statusCode ?? 0) < 400) { + res.resume() + reject(new Error(t('proxyBlocked'))) + return + } + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + resolve({ + status: res.statusCode ?? 502, + text: Buffer.concat(chunks).subarray(0, 200_000).toString('utf8'), + contentType: String(res.headers['content-type'] ?? 'application/json; charset=utf-8'), + }) + }) + }, + ) + const fail = (error: Error) => reject(error.message === 'blocked' ? new Error(t('proxyBlocked')) : error) + req.on('error', fail) + signal?.addEventListener('abort', () => req.destroy(new Error('aborted')), { once: true }) + if (body) req.write(body) + req.end() + }) } diff --git a/mcp/bridge.ts b/mcp/bridge.ts index 398b590..8e574f4 100644 --- a/mcp/bridge.ts +++ b/mcp/bridge.ts @@ -2,6 +2,7 @@ import { t } from '../src/i18n/locale.ts' import { randomUUID } from 'node:crypto' import type { IncomingMessage, ServerResponse } from 'node:http' import { readBody, sendEmpty, sendJson } from './http.ts' +import { peerAllowed } from './peer.ts' export const BRIDGE_HEADER = 'x-mathscrap-bridge' const MARK = { [BRIDGE_HEADER]: '1' } @@ -59,6 +60,11 @@ export function createBridge() { async function handle(req: IncomingMessage, res: ServerResponse) { const path = (req.url ?? '').split('?')[0] ?? '' if (!path.startsWith('/__mathscrap/bridge')) return false + const browserRoute = path === '/__mathscrap/bridge/wait' || path === '/__mathscrap/bridge/result' || path === '/__mathscrap/bridge/health' + if (!peerAllowed(req, browserRoute)) { + sendJson(res, 403, { ok: false, error: 'refused' }, MARK) + return true + } try { if (req.method === 'GET' && path === '/__mathscrap/bridge/health') { sendJson(res, 200, { ok: true, browser: browserLive() }, MARK) diff --git a/mcp/peer.ts b/mcp/peer.ts new file mode 100644 index 0000000..4d7a9d0 --- /dev/null +++ b/mcp/peer.ts @@ -0,0 +1,134 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { isIP } from 'node:net' +import { presentedSession, sessionCookieHeader, sessionMatches, shareMatches, shareTokenUsable } from './session.ts' + +/** + * Loopback bridge authorization. + * Origin and Sec-Fetch-Site are not the capability: a missing or forged + * pair is refused unless the HttpOnly session cookie matches. + * The cookie is issued on a browser document navigation, or on an explicit + * LAN join that presents the operator's share token. + * A same-machine process that forges a document navigation can still obtain + * the cookie. A same-user process can read the session file. That remains. + */ +export function peerAllowed(req: IncomingMessage, browserRoute: boolean): boolean { + if (isWebsocket(req)) return false + if (!hostMatchesConnection(req)) return false + if (!sessionMatches(presentedSession(req))) return false + const origin = header(req, 'origin') + if (origin === 'null') return false + if (origin && !localOrigin(origin) && !lanOrigin(req, origin)) return false + if (origin && !sameHost(origin, header(req, 'host'))) return false + if (browserRoute && !origin) return sameOriginGet(req) + return true +} + +export function stampNavigation(req: IncomingMessage, res: ServerResponse): boolean { + if (req.method !== 'GET' && req.method !== 'HEAD') return false + if (!isLoopback(req.socket?.remoteAddress)) return false + if (!hostIsLoopback(header(req, 'host'))) return false + if (header(req, 'sec-fetch-dest') !== 'document' || header(req, 'sec-fetch-mode') !== 'navigate') return false + appendCookie(res) + return true +} + +/** Share token is exchanged for the session cookie. It does not authorize /call by itself. */ +export function lanJoinAllowed(req: IncomingMessage, share: string): boolean { + if (!shareTokenUsable() || !shareMatches(share)) return false + if (isLoopback(req.socket?.remoteAddress)) return false + return lanHostOk(header(req, 'host'), req.socket?.localAddress) +} + +export function lanHostOk(hostHeader: string, localAddress: string | undefined): boolean { + const host = hostName(hostHeader) + const local = normalizeIp(localAddress) + return isIP(host) !== 0 && host === local && !isLoopback(local) +} + +export function isLoopback(address: string | undefined): boolean { + const host = normalizeIp(address) + return host === '::1' || host.startsWith('127.') +} + +export function localOrigin(origin: string): boolean { + try { + const url = new URL(origin) + const host = url.hostname.toLowerCase() + return (url.protocol === 'http:' || url.protocol === 'https:') && (host === 'localhost' || host === '127.0.0.1' || host === '::1') + } catch { + return false + } +} + +export function hostIsLoopback(hostHeader: string): boolean { + const host = hostName(hostHeader) + return host === 'localhost' || host === '127.0.0.1' || host === '::1' +} + +function hostMatchesConnection(req: IncomingMessage): boolean { + if (isLoopback(req.socket?.remoteAddress)) return hostIsLoopback(header(req, 'host')) + if (!shareTokenUsable()) return false + return lanHostOk(header(req, 'host'), req.socket?.localAddress) +} + +function lanOrigin(req: IncomingMessage, origin: string): boolean { + if (!shareTokenUsable() || isLoopback(req.socket?.remoteAddress)) return false + try { + const url = new URL(origin) + return (url.protocol === 'http:' || url.protocol === 'https:') && lanHostOk(url.hostname, req.socket?.localAddress) + } catch { + return false + } +} + +function sameHost(origin: string, hostHeader: string): boolean { + try { + return new URL(origin).hostname.toLowerCase() === hostName(hostHeader) + } catch { + return false + } +} + +function sameOriginGet(req: IncomingMessage): boolean { + const method = (req.method ?? 'GET').toUpperCase() + if (method !== 'GET' && method !== 'HEAD') return false + return header(req, 'sec-fetch-site').toLowerCase() === 'same-origin' +} + +function isWebsocket(req: IncomingMessage): boolean { + const upgrade = header(req, 'upgrade').toLowerCase() + return upgrade === 'websocket' || upgrade.split(',').some((part) => part.trim() === 'websocket') +} + +export function grantSession(res: ServerResponse) { + appendCookie(res) +} + +function appendCookie(res: ServerResponse) { + const value = sessionCookieHeader() + const prev = res.getHeader('set-cookie') + if (!prev) res.setHeader('set-cookie', value) + else if (Array.isArray(prev)) res.setHeader('set-cookie', [...prev.map(String), value]) + else res.setHeader('set-cookie', [String(prev), value]) +} + +export function normalizeIp(address: string | undefined): string { + if (!address) return '' + const host = address.toLowerCase().replace(/^\[|\]$/g, '').split('%')[0] + const mapped = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/) + return mapped ? mapped[1] : host +} + +function hostName(hostHeader: string): string { + const value = hostHeader.trim().toLowerCase() + if (value.startsWith('[')) { + const end = value.indexOf(']') + return end > 1 ? value.slice(1, end) : '' + } + return value.split(':')[0] ?? '' +} + +function header(req: IncomingMessage, name: string): string { + const value = req.headers[name] + return typeof value === 'string' ? value : '' +} diff --git a/mcp/server.ts b/mcp/server.ts index e38abb4..764d2ca 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -9,15 +9,22 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { TOOL_SPECS } from '../src/ai/specs.ts' +import { readPublishedSession } from './session.ts' const bridgeUrl = (process.env.MATHSCRAP_BRIDGE ?? 'http://127.0.0.1:47321').replace(/\/+$/, '') async function bridgeCall(name: string, args: Record) { + let session = '' + try { + session = readPublishedSession() + } catch { + throw new Error(`连不上 MathScrap(${bridgeUrl})。先在本机运行 npm run dev,并打开页面。`) + } let res: Response try { res = await fetch(`${bridgeUrl}/__mathscrap/bridge/call`, { method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, + headers: { 'content-type': 'application/json', accept: 'application/json', cookie: `mathscrap_session=${session}` }, body: JSON.stringify({ name, arguments: args }), signal: AbortSignal.timeout(20_000), }) diff --git a/mcp/session.ts b/mcp/session.ts new file mode 100644 index 0000000..eba7810 --- /dev/null +++ b/mcp/session.ts @@ -0,0 +1,83 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type { IncomingMessage } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const COOKIE = 'mathscrap_session' + +let memory = '' + +/** Process-local capability. Not a header the caller can invent. */ +export function sessionToken(): string { + if (!memory) memory = randomBytes(32).toString('hex') + return memory +} + +export function sessionFile(): string { + return path.join(tmpdir(), `mathscrap-session-${process.getuid?.() ?? 'user'}`, 'session') +} + +/** Mode 0600. The stdio client reads this. Do not print the value. */ +export function publishSession(): void { + const file = sessionFile() + mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }) + chmodSync(path.dirname(file), 0o700) + writeFileSync(file, sessionToken(), { mode: 0o600 }) + chmodSync(file, 0o600) +} + +export function readPublishedSession(): string { + return readFileSync(sessionFile(), 'utf8').trim() +} + +export function sessionCookieHeader(): string { + return `${COOKIE}=${sessionToken()}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400` +} + +export function presentedSession(req: IncomingMessage): string { + const raw = header(req, 'cookie') + for (const part of raw.split(';')) { + const text = part.trim() + const eq = text.indexOf('=') + if (eq <= 0) continue + if (text.slice(0, eq) === COOKIE) return text.slice(eq + 1) + } + return '' +} + +export function sessionMatches(value: string): boolean { + const token = sessionToken() + const got = Buffer.from(value) + const want = Buffer.from(token) + if (got.length !== want.length || got.length === 0) return false + return timingSafeEqual(got, want) +} + +/** + * LAN sharing stays off unless the operator set a long unguessable token + * and asked the server to listen beyond loopback. The token is not generated + * into a log line. + */ +export function shareTokenUsable(value = process.env.MATHSCRAP_SHARE_TOKEN ?? ''): boolean { + if (process.env.MATHSCRAP_BIND !== 'lan') return false + if (value.length < 32 || value.length > 256) return false + if (new Set(value).size < 8) return false + if (/^(.)\1+$/.test(value)) return false + if (/changeme|password|secret|mathscrap|letmein|qwerty/i.test(value)) return false + return true +} + +export function shareMatches(value: string): boolean { + const want = process.env.MATHSCRAP_SHARE_TOKEN ?? '' + if (!shareTokenUsable(want)) return false + const got = Buffer.from(value) + const expect = Buffer.from(want) + if (got.length !== expect.length) return false + return timingSafeEqual(got, expect) +} + +function header(req: IncomingMessage, name: string): string { + const value = req.headers[name] + return typeof value === 'string' ? value : '' +} diff --git a/mcp/ssrf.ts b/mcp/ssrf.ts new file mode 100644 index 0000000..9e59c78 --- /dev/null +++ b/mcp/ssrf.ts @@ -0,0 +1,226 @@ +/** + * Decide whether an AI proxy target is safe to open. + * Link-local and metadata addresses are always refused. + * Loopback, private, and IPv6 ULA addresses are refused unless the exact + * origin (scheme, host, and port) is listed in MATHSCRAP_AI_LOCAL_ORIGINS. + * That list does not open the rest of the private network. + */ +import { BlockList, isIP } from 'node:net' +import { lookup } from 'node:dns/promises' + +const linkLocal4 = new BlockList() +const private4 = new BlockList() +const linkLocal6 = new BlockList() +const private6 = new BlockList() + +linkLocal4.addSubnet('169.254.0.0', 16, 'ipv4') +linkLocal4.addSubnet('0.0.0.0', 8, 'ipv4') +private4.addSubnet('10.0.0.0', 8, 'ipv4') +private4.addSubnet('100.64.0.0', 10, 'ipv4') +private4.addSubnet('127.0.0.0', 8, 'ipv4') +private4.addSubnet('172.16.0.0', 12, 'ipv4') +private4.addSubnet('192.0.0.0', 24, 'ipv4') +private4.addSubnet('192.0.2.0', 24, 'ipv4') +private4.addSubnet('192.168.0.0', 16, 'ipv4') +private4.addSubnet('198.18.0.0', 15, 'ipv4') +private4.addSubnet('198.51.100.0', 24, 'ipv4') +private4.addSubnet('203.0.113.0', 24, 'ipv4') +private4.addSubnet('224.0.0.0', 4, 'ipv4') +private4.addSubnet('240.0.0.0', 4, 'ipv4') + +linkLocal6.addSubnet('fe80::', 10, 'ipv6') +linkLocal6.addSubnet('::', 128, 'ipv6') +private6.addSubnet('::1', 128, 'ipv6') +private6.addSubnet('fc00::', 7, 'ipv6') +private6.addSubnet('ff00::', 8, 'ipv6') +private6.addSubnet('2001:db8::', 32, 'ipv6') + +export type Addr = { address: string; family: number } +export type LookupFn = (hostname: string) => Promise + +const METADATA_HOSTS = new Set(['metadata.google.internal', 'metadata.google.com']) + +export function localOriginsFromEnv(): URL[] { + const raw = process.env.MATHSCRAP_AI_LOCAL_ORIGINS ?? '' + const out: URL[] = [] + for (const part of raw.split(',')) { + const text = part.trim() + if (!text) continue + try { + const url = new URL(text) + if ((url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password) out.push(url) + } catch { + continue + } + } + return out +} + +export function matchesLocalOrigin(target: URL, allow: URL[]): boolean { + const host = stripBrackets(target.hostname).toLowerCase() + return allow.some((item) => item.protocol === target.protocol && stripBrackets(item.hostname).toLowerCase() === host && portOf(item) === portOf(target)) +} + +export function isLinkLocalAddress(address: string): boolean { + return expandAddress(address).some((form) => { + if (form.family === 4) return linkLocal4.check(form.address, 'ipv4') + if (form.family === 6 && isIP(form.address) === 6) return linkLocal6.check(form.address, 'ipv6') + return form.family === 0 + }) +} + +export function isPrivateOrLoopback(address: string): boolean { + return expandAddress(address).some((form) => { + if (form.family === 4) return private4.check(form.address, 'ipv4') || linkLocal4.check(form.address, 'ipv4') + if (form.family === 6 && isIP(form.address) === 6) return private6.check(form.address, 'ipv6') || linkLocal6.check(form.address, 'ipv6') + return true + }) +} + +export function isBlockedAddress(address: string, allowPrivate = false): boolean { + if (isLinkLocalAddress(address)) return true + return !allowPrivate && isPrivateOrLoopback(address) +} + +export function isBlockedHostname(hostname: string, allowPrivate = false): boolean { + const host = stripBrackets(hostname).toLowerCase().replace(/\.$/, '') + if (!host) return true + if (METADATA_HOSTS.has(host) || (host.endsWith('.internal') && host.includes('metadata'))) return true + const literal = literalAddresses(host) + if (literal.length) return literal.some((item) => isBlockedAddress(item.address, allowPrivate)) + return false +} + +export async function resolveAndCheck(target: URL, lookupFn: LookupFn = defaultLookup, origins: URL[] = localOriginsFromEnv()): Promise { + const host = stripBrackets(target.hostname).toLowerCase().replace(/\.$/, '') + if (!host || METADATA_HOSTS.has(host) || (host.endsWith('.internal') && host.includes('metadata'))) throw new Error('blocked') + const literal = literalAddresses(host) + if (literal.some((item) => isLinkLocalAddress(item.address))) throw new Error('blocked') + const found = literal.length ? literal : await lookupFn(host) + if (!found.length) throw new Error('blocked') + const permitted = matchesLocalOrigin(target, origins) + if (found.some((item) => isLinkLocalAddress(item.address))) throw new Error('blocked') + if (found.some((item) => isPrivateOrLoopback(item.address)) && !permitted) throw new Error('blocked') + return found +} + +function portOf(url: URL) { + if (url.port) return url.port + return url.protocol === 'https:' ? '443' : '80' +} + +export function sameAddressSet(left: Addr[], right: Addr[]): boolean { + const key = (item: Addr) => `${item.family}:${item.address}` + const a = [...new Set(left.map(key))].sort() + const b = [...new Set(right.map(key))].sort() + return a.length === b.length && a.every((item, i) => item === b[i]) +} + +export function defaultLookup(hostname: string): Promise { + return lookup(hostname, { all: true, verbatim: true }) +} + +export function literalAddresses(host: string): Addr[] { + const bare = stripBrackets(host).toLowerCase().split('%')[0] + if (isIP(bare) === 4) return [{ address: bare, family: 4 }] + if (bare.includes(':') && parseIpv6(bare)) return [{ address: bare, family: 6 }] + const dword = dwordToIpv4(bare) + if (dword) return [{ address: dword, family: 4 }] + const dotted = dottedToIpv4(bare) + if (dotted) return [{ address: dotted, family: 4 }] + return [] +} + +function expandAddress(address: string): Addr[] { + const bare = stripBrackets(address).toLowerCase().split('%')[0] + const out: Addr[] = [] + if (isIP(bare) === 4) out.push({ address: bare, family: 4 }) + const groups = bare.includes(':') ? parseIpv6(bare) : null + if (groups) { + out.push({ address: bare, family: 6 }) + for (const embedded of embeddedIpv4(groups)) out.push({ address: embedded, family: 4 }) + } + const dword = dwordToIpv4(bare) + if (dword) out.push({ address: dword, family: 4 }) + return out.length ? out : [{ address: bare, family: 0 }] +} + +function parseIpv6(input: string): number[] | null { + let text = input.toLowerCase() + const dotted = text.match(/(\d{1,3}(?:\.\d{1,3}){3})$/) + let v4: number[] = [] + if (dotted && dotted.index != null) { + const parsed = ipv4ToGroups(dotted[1]) + if (!parsed) return null + v4 = parsed + text = text.slice(0, dotted.index).replace(/:$/, '') + } + const halves = text.split('::') + if (halves.length > 2) return null + const side = (part: string) => { + if (!part) return [] as number[] + const bits = part.split(':') + if (bits.some((item) => !/^[0-9a-f]{1,4}$/.test(item))) return null + return bits.map((item) => parseInt(item, 16)) + } + const left = side(halves[0] ?? '') + const right = halves.length === 2 ? side(halves[1] ?? '') : [] + if (!left || !right) return null + if (halves.length === 1) return left.length + v4.length === 8 ? [...left, ...v4] : null + const missing = 8 - (left.length + right.length + v4.length) + if (missing < 0) return null + return [...left, ...Array(missing).fill(0), ...right, ...v4] +} + +function embeddedIpv4(groups: number[]): string[] { + const out: string[] = [] + const zero = (count: number) => groups.slice(0, count).every((item) => item === 0) + if (groups[0] === 0x2002) out.push(groupsToIpv4(groups[1], groups[2])) + if (groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((item) => item === 0)) out.push(groupsToIpv4(groups[6], groups[7])) + if (zero(5) && groups[5] === 0xffff) out.push(groupsToIpv4(groups[6], groups[7])) + if (zero(6)) out.push(groupsToIpv4(groups[6], groups[7])) + return out +} + +function groupsToIpv4(hi: number, lo: number): string { + const n = ((hi << 16) | lo) >>> 0 + return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.') +} + +function ipv4ToGroups(text: string): number[] | null { + const parts = text.split('.') + if (parts.length !== 4) return null + const nums = parts.map(parsePart) + if (nums.some((item) => item == null)) return null + const [a, b, c, d] = nums as number[] + return [((a << 8) | b) & 0xffff, ((c << 8) | d) & 0xffff] +} + +function dwordToIpv4(text: string): string | null { + if (!/^(0x[0-9a-f]+|\d+)$/i.test(text)) return null + const n = text.startsWith('0x') || text.startsWith('0X') ? parseInt(text, 16) : Number(text) + if (!Number.isSafeInteger(n) || n < 0 || n > 0xffffffff) return null + return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.') +} + +function dottedToIpv4(text: string): string | null { + const parts = text.split('.') + if (parts.length !== 4) return null + const nums = parts.map(parsePart) + if (nums.some((n) => n == null)) return null + return (nums as number[]).join('.') +} + +function parsePart(part: string): number | null { + if (/^0x[0-9a-f]+$/i.test(part)) { + const n = parseInt(part, 16) + return n >= 0 && n <= 255 ? n : null + } + if (!/^\d+$/.test(part)) return null + const n = Number(part) + return n >= 0 && n <= 255 ? n : null +} + +function stripBrackets(host: string) { + return host.replace(/^\[|\]$/g, '') +} diff --git a/mcp/vite-plugin.ts b/mcp/vite-plugin.ts index 75672e1..bae7ef2 100644 --- a/mcp/vite-plugin.ts +++ b/mcp/vite-plugin.ts @@ -2,15 +2,23 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { Plugin } from 'vite' import { handleAiProxy } from './ai-proxy.ts' import { createBridge } from './bridge.ts' +import { readBody, sendJson } from './http.ts' +import { grantSession, lanJoinAllowed, stampNavigation } from './peer.ts' +import { publishSession } from './session.ts' /** * Dev/preview middleware: the open page and the MCP process meet on localhost. - * Not part of the client bundle. + * Not part of the client bundle. The session value is not written into the page. */ export function mathscrapDevBridge(): Plugin { + publishSession() const bridge = createBridge() const middleware = (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => { const path = (req.url ?? '').split('?')[0] ?? '' + if (path === '/__mathscrap/join') { + void handleJoin(req, res) + return + } if (path.startsWith('/__mathscrap/bridge')) { void bridge.handle(req, res) return @@ -19,6 +27,7 @@ export function mathscrapDevBridge(): Plugin { void handleAiProxy(req, res) return } + stampNavigation(req, res) next() } return { @@ -31,3 +40,23 @@ export function mathscrapDevBridge(): Plugin { }, } } + +async function handleJoin(req: IncomingMessage, res: ServerResponse) { + if (req.method !== 'POST') { + sendJson(res, 405, { ok: false, error: 'refused' }) + return + } + let share = '' + try { + const body = JSON.parse(await readBody(req, 4_000)) as { share?: unknown } + share = typeof body.share === 'string' ? body.share : '' + } catch { + share = '' + } + if (!lanJoinAllowed(req, share)) { + sendJson(res, 403, { ok: false, error: 'refused' }) + return + } + grantSession(res) + sendJson(res, 200, { ok: true }) +} diff --git a/scripts/ai-mcp-smoke.ts b/scripts/ai-mcp-smoke.ts index 8d1de95..7d4aecf 100644 --- a/scripts/ai-mcp-smoke.ts +++ b/scripts/ai-mcp-smoke.ts @@ -6,7 +6,7 @@ * the Vite dev server hosts, then asks the real MCP server to list and call tools. */ import assert from 'node:assert/strict' -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { createServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'node:http' import { fileURLToPath } from 'node:url' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' @@ -16,11 +16,28 @@ import { emphasizeVectors } from '../src/ai/formulas.ts' import { normalizePlotInput } from '../src/plot/remoteView.ts' import { createBridge } from '../mcp/bridge.ts' import { forwardAi, handleAiProxy, isBlockedHost } from '../mcp/ai-proxy.ts' +import { lanHostOk, lanJoinAllowed, stampNavigation } from '../mcp/peer.ts' +import { publishSession, sessionToken, shareTokenUsable } from '../mcp/session.ts' + +publishSession() +const sessionCookie = `mathscrap_session=${sessionToken()}` const root = fileURLToPath(new URL('..', import.meta.url)) const SECRET = 'sk-testsecretvalue-SHOULD-NOT-LEAK' const OK_KEY = 'sk-ok-DO-NOT-LOG' +function rawStatus(port: number, path: string, headers: Record, method = 'GET', body?: string) { + return new Promise((resolve, reject) => { + const req = httpRequest({ hostname: '127.0.0.1', port, path, method, headers }, (res) => { + res.resume() + resolve(res.statusCode ?? 0) + }) + req.on('error', reject) + if (body) req.end(body) + else req.end() + }) +} + function listen(server: ReturnType) { return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { @@ -211,6 +228,16 @@ try { assert.match(fallback.text, /y 轴/) assert.equal(isBlockedHost('169.254.169.254'), true) + assert.equal(isBlockedHost('[::ffff:a9fe:a9fe]'), true) + assert.equal(isBlockedHost('::ffff:169.254.169.254'), true) + assert.equal(isBlockedHost('2852039166'), true) + assert.equal(isBlockedHost('127.0.0.1'), true) + assert.equal(isBlockedHost('10.1.1.1'), true) + assert.equal(isBlockedHost('0:0:0:0:0:ffff:a9fe:a9fe'), true) + assert.equal(isBlockedHost('2002:a9fe:a9fe::'), true) + assert.equal(isBlockedHost('64:ff9b::a9fe:a9fe'), true) + assert.equal(isBlockedHost('fe80:0:0:0:0:0:0:1'), true) + assert.equal(isBlockedHost('::ffff:10.1.1.1'), true) let forwarded = false await assert.rejects( () => @@ -220,24 +247,154 @@ try { }), /refused/i, ) + await assert.rejects( + () => + forwardAi({ url: 'http://127.0.0.1/v1/models', method: 'GET' }, async () => { + forwarded = true + return new Response('no') + }), + /refused/i, + ) + await assert.rejects( + () => + forwardAi( + { url: 'http://models.example/v1/models', method: 'GET' }, + async () => { + forwarded = true + return new Response('no') + }, + undefined, + { lookup: async () => [{ address: '10.1.1.1', family: 4 }] }, + ), + /refused/i, + ) + await assert.rejects( + () => + forwardAi( + { url: 'http://mixed.example/v1/models', method: 'GET' }, + async () => { + forwarded = true + return new Response('no') + }, + undefined, + { lookup: async () => [{ address: '1.1.1.1', family: 4 }, { address: '10.1.1.1', family: 4 }] }, + ), + /refused/i, + ) + await assert.rejects( + () => + forwardAi( + { url: 'http://link.example/v1/models', method: 'GET' }, + async () => { + forwarded = true + return new Response('no') + }, + undefined, + { lookup: async () => [{ address: '8.8.8.8', family: 4 }, { address: '169.254.169.254', family: 4 }] }, + ), + /refused/i, + ) assert.equal(forwarded, false) + await assert.rejects( + () => + forwardAi({ url: 'http://10.2.2.2/v1/models', method: 'GET' }, async () => new Response('no'), undefined, { + localOrigins: [new URL('http://127.0.0.1:9')], + }), + /refused/i, + ) + await assert.rejects( + () => + forwardAi({ url: 'http://[fd00::1]/v1/models', method: 'GET' }, async () => new Response('no'), undefined, { + localOrigins: [new URL('http://127.0.0.1:9')], + }), + /refused/i, + ) + await assert.rejects( + () => + forwardAi({ url: 'http://169.254.169.254/latest/meta-data/models', method: 'GET' }, async () => new Response('no'), undefined, { + localOrigins: [new URL('http://169.254.169.254')], + }), + /refused/i, + ) + await assert.rejects( + () => + forwardAi({ url: 'http://[0:0:0:0:0:ffff:a9fe:a9fe]/latest/meta-data/models', method: 'GET' }, async () => new Response('no'), undefined, { + localOrigins: [new URL('http://[0:0:0:0:0:ffff:a9fe:a9fe]')], + }), + /refused/i, + ) + await assert.rejects( + () => + forwardAi({ url: 'http://[2002:a9fe:a9fe::]/v1/models', method: 'GET' }, async () => new Response('no'), undefined, { + localOrigins: [new URL('http://[2002:a9fe:a9fe::]')], + }), + /refused/i, + ) + let ula = false + await forwardAi({ url: 'http://[fd00::2]:11434/v1/models', method: 'GET' }, async () => { + ula = true + return new Response('{}', { status: 200 }) + }, undefined, { localOrigins: [new URL('http://[fd00::2]:11434')] }) + assert.equal(ula, true) const proxy = createServer((req, res) => { void handleAiProxy(req, res) }) const proxyPort = await listen(proxy) - const proxied = await fetch(`http://127.0.0.1:${proxyPort}/__mathscrap/ai/forward`, { + const evil = await fetch(`http://127.0.0.1:${proxyPort}/__mathscrap/ai/forward`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'https://evil.example', cookie: sessionCookie, 'sec-fetch-site': 'same-origin' }, + body: JSON.stringify({ url: `${base}/v1/models`, method: 'GET' }), + }) + assert.equal(evil.status, 403) + const noOrigin = await fetch(`http://127.0.0.1:${proxyPort}/__mathscrap/ai/forward`, { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: `${base}/v1/models`, method: 'GET' }), + }) + assert.equal(noOrigin.status, 403) + process.env.MATHSCRAP_AI_LOCAL_ORIGINS = base + const proxied = await fetch(`http://127.0.0.1:${proxyPort}/__mathscrap/ai/forward`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: `http://127.0.0.1:${proxyPort}`, cookie: sessionCookie }, body: JSON.stringify({ url: `${base}/v1/models`, method: 'GET', headers: { authorization: `Bearer ${SECRET}` } }), }) assert.equal(proxied.headers.get('x-mathscrap-proxy'), '1') assert.equal(proxied.status, 401) const proxiedBody = await proxied.text() assert.ok(proxiedBody.includes('SHOULD-NOT-LEAK'), 'upstream body may echo a key; the proxy must not be the thing that logs it') + delete process.env.MATHSCRAP_AI_LOCAL_ORIGINS proxy.close() - assert.ok(!logs.logs.some((line) => line.includes('SHOULD-NOT-LEAK') || line.includes('DO-NOT-LOG')), `API key leaked to console:\n${logs.logs.join('\n')}`) + const hop = createServer((_req, res) => { + res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' }) + res.end() + }) + const hopPort = await listen(hop) + let lookups = 0 + await assert.rejects( + () => + forwardAi( + { url: `http://rebind.example:${hopPort}/v1/models`, method: 'GET' }, + undefined, + AbortSignal.timeout(2000), + { + localOrigins: [new URL(`http://rebind.example:${hopPort}`)], + lookup: async () => [{ address: lookups++ === 0 ? '127.0.0.1' : '10.9.9.9', family: 4 }], + }, + ), + /refused|blocked/i, + ) + await assert.rejects( + () => + forwardAi({ url: `http://127.0.0.1:${hopPort}/v1/models`, method: 'GET' }, undefined, AbortSignal.timeout(2000), { + localOrigins: [new URL(`http://127.0.0.1:${hopPort}`)], + }), + /refused/i, + ) + hop.close() + + assert.ok(!logs.logs.some((line) => line.includes('SHOULD-NOT-LEAK') || line.includes('DO-NOT-LOG') || line.includes(sessionToken())), 'secret leaked to console') } finally { logs.restore() await new Promise((resolve) => ai.close(() => resolve())) @@ -249,17 +406,74 @@ const bridgeServer = createServer((req, res) => { }) const bridgePort = await listen(bridgeServer) const bridgeUrl = `http://127.0.0.1:${bridgePort}` +const pageOrigin = { origin: bridgeUrl, host: `127.0.0.1:${bridgePort}`, cookie: sessionCookie } + +const forged = await fetch(`${bridgeUrl}/__mathscrap/bridge/call`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'https://evil.example', cookie: sessionCookie, 'sec-fetch-site': 'same-origin' }, + body: JSON.stringify({ name: 'page_add_formula', arguments: { text: 'y = 1' } }), +}) +assert.equal(forged.status, 403) +const forgedBody = await forged.text() +assert.equal(forgedBody.includes(sessionToken()), false) +const nullOrigin = await rawStatus( + bridgePort, + '/__mathscrap/bridge/result', + { 'content-type': 'application/json', origin: 'null', host: `127.0.0.1:${bridgePort}`, cookie: sessionCookie }, + 'POST', + JSON.stringify({ id: 'x', ok: true }), +) +assert.equal(nullOrigin, 403) +const rebound = await rawStatus(bridgePort, '/__mathscrap/bridge/wait', { + origin: bridgeUrl, + host: 'evil.example', + cookie: sessionCookie, + 'sec-fetch-site': 'same-origin', +}) +assert.equal(rebound, 403) +const upgrade = await rawStatus( + bridgePort, + '/__mathscrap/bridge/call', + { 'content-type': 'application/json', upgrade: 'websocket', connection: 'Upgrade', host: `127.0.0.1:${bridgePort}`, cookie: sessionCookie }, + 'POST', + JSON.stringify({ name: 'page_add_formula', arguments: {} }), +) +assert.equal(upgrade, 403) +const spoofedGet = await rawStatus(bridgePort, '/__mathscrap/bridge/health', { + host: `127.0.0.1:${bridgePort}`, + 'sec-fetch-site': 'same-origin', + origin: bridgeUrl, +}) +assert.equal(spoofedGet, 403) +const sameOriginGet = await rawStatus(bridgePort, '/__mathscrap/bridge/health', { + host: `127.0.0.1:${bridgePort}`, + 'sec-fetch-site': 'same-origin', + cookie: sessionCookie, +}) +assert.equal(sameOriginGet, 200) +const bareGet = await rawStatus(bridgePort, '/__mathscrap/bridge/wait', { host: `127.0.0.1:${bridgePort}` }) +assert.equal(bareGet, 403) +const barePost = await rawStatus( + bridgePort, + '/__mathscrap/bridge/result', + { host: `127.0.0.1:${bridgePort}`, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' }, + 'POST', + JSON.stringify({ id: 'x', ok: true }), +) +assert.equal(barePost, 403) const offlineAt = Date.now() const offline = await fetch(`${bridgeUrl}/__mathscrap/bridge/call`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie: sessionCookie }, body: JSON.stringify({ name: 'page_read', arguments: {} }), }) const offlineBody = (await offline.json()) as { ok: boolean; error?: string } assert.equal(offlineBody.ok, false) assert.match(offlineBody.error ?? '', /not connected/) assert.ok(Date.now() - offlineAt < 500, 'disconnected tool call hung') +const pageWait = await fetch(`${bridgeUrl}/__mathscrap/bridge/wait`, { headers: pageOrigin }) +assert.equal(pageWait.status, 204) const state = { formulas: [{ id: 'c1', text: 'y = x^2 - 2', x: 10, y: 20 }], @@ -269,7 +483,7 @@ const state = { const stop = new AbortController() const browser = (async () => { while (!stop.signal.aborted) { - const res = await fetch(`${bridgeUrl}/__mathscrap/bridge/wait`, { signal: stop.signal }) + const res = await fetch(`${bridgeUrl}/__mathscrap/bridge/wait`, { signal: stop.signal, headers: pageOrigin }) if (res.status === 204) { await new Promise((r) => setTimeout(r, 40)) continue @@ -288,7 +502,7 @@ const browser = (async () => { else if (job.name === 'solve_analyze') result = { texts: job.arguments.texts } await fetch(`${bridgeUrl}/__mathscrap/bridge/result`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...pageOrigin }, body: JSON.stringify({ id: job.id, ok: true, result }), signal: stop.signal, }) @@ -300,7 +514,7 @@ const browser = (async () => { const healthDeadline = Date.now() + 2000 let connected = false while (Date.now() < healthDeadline) { - const health = (await (await fetch(`${bridgeUrl}/__mathscrap/bridge/health`)).json()) as { browser?: boolean } + const health = (await (await fetch(`${bridgeUrl}/__mathscrap/bridge/health`, { headers: pageOrigin })).json()) as { browser?: boolean } if (health.browser) { connected = true break @@ -370,4 +584,57 @@ function textOf(result: unknown) { return content.map((block) => (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string' ? block.text : '')).join('\n') } +const nav = createServer((req, res) => { + const stamped = stampNavigation(req, res) + res.statusCode = stamped ? 200 : 204 + res.end(stamped ? 'page' : '') +}) +const navPort = await listen(nav) +const navRes = await new Promise<{ status: number; cookie: string; body: string }>((resolve, reject) => { + const req = httpRequest( + { + hostname: '127.0.0.1', + port: navPort, + path: '/', + method: 'GET', + headers: { host: `127.0.0.1:${navPort}`, 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => resolve({ status: res.statusCode ?? 0, cookie: String(res.headers['set-cookie'] ?? ''), body: Buffer.concat(chunks).toString('utf8') })) + }, + ) + req.on('error', reject) + req.end() +}) +assert.equal(navRes.status, 200) +assert.match(navRes.cookie, /HttpOnly/) +assert.match(navRes.cookie, /SameSite=Strict/) +assert.equal(navRes.body.includes(sessionToken()), false) +const plain = await rawStatus(navPort, '/', { host: `127.0.0.1:${navPort}` }) +assert.equal(plain, 204) +nav.close() + +const share = 'k3-7Qm9_pL2vN8xR4wT6yU1zA0bC5dEf' +assert.equal(shareTokenUsable('short'), false) +assert.equal(shareTokenUsable('a'.repeat(40)), false) +assert.equal(shareTokenUsable('mathscrap-share-token-0123456789'), false) +process.env.MATHSCRAP_BIND = 'lan' +process.env.MATHSCRAP_SHARE_TOKEN = share +assert.equal(shareTokenUsable(), true) +assert.equal(lanHostOk('evil.example', '192.168.1.5'), false) +assert.equal(lanHostOk('192.168.1.5', '192.168.1.5'), true) +const lanReq = { + method: 'POST', + headers: { host: '192.168.1.5' }, + socket: { remoteAddress: '192.168.1.20', localAddress: '192.168.1.5' }, +} as IncomingMessage +assert.equal(lanJoinAllowed(lanReq, share), true) +assert.equal(lanJoinAllowed(lanReq, 'nope'), false) +assert.equal(lanJoinAllowed({ ...lanReq, socket: { remoteAddress: '127.0.0.1', localAddress: '127.0.0.1' } } as IncomingMessage, share), false) +delete process.env.MATHSCRAP_BIND +delete process.env.MATHSCRAP_SHARE_TOKEN +assert.equal(stderr.includes(sessionToken()), false) + console.log('ai + mcp smoke ok') diff --git a/scripts/ci-scope-smoke.mjs b/scripts/ci-scope-smoke.mjs index a934b0f..3f7bd0a 100644 --- a/scripts/ci-scope-smoke.mjs +++ b/scripts/ci-scope-smoke.mjs @@ -63,6 +63,13 @@ check('test script', classifyEntries([ entry('scripts/ci-scope.mjs', 'export const a = 1\n', 'export const a = 2\n'), ]).mode, 'full') +check('ocr path is full', classifyEntries([ + entry('src/ocr/gate.ts', 'export const a = 1\n', 'export const a = 2\n'), +]).mode, 'full') +check('ocr smoke script is full', classifyEntries([ + entry('scripts/ocr-real-smoke.ts', 'export const a = 1\n', 'export const a = 2\n'), +]).mode, 'full') + check('unknown', classifyEntries([ entry('src/new-file.ts', 'a\n', 'b\n'), ]).mode, 'full') diff --git a/scripts/ocr-model-pins.json b/scripts/ocr-model-pins.json new file mode 100644 index 0000000..4b4f5e7 --- /dev/null +++ b/scripts/ocr-model-pins.json @@ -0,0 +1,5 @@ +{ + "encoder_int8.onnx": "272f17c7b120db61db6cd8d77e695d0139144044abd57d9b23284435a5d916fa", + "decoder_int8.onnx": "658901d81ca3aa4b62b0da262e8fe67ebcfdac19cfb52ca2cfd076bebd481088", + "vocab.json": "1d75cbbea3bed558683d7e9c3b3c72069ef2143a155d5f8c610f85a281af000c" +} diff --git a/scripts/ocr-real-smoke.ts b/scripts/ocr-real-smoke.ts new file mode 100644 index 0000000..c328972 --- /dev/null +++ b/scripts/ocr-real-smoke.ts @@ -0,0 +1,131 @@ +/** + * Gate checks plus a few real CoMER INT8 readings. + * Strokes are synthetic polylines, not scanned handwriting. + * Numbers below are model confidence, not an accuracy rate. + * The repo has no redistributable scan of a person's handwriting. + * Missing model files fail this script. They are not a pass. + * + * npx tsx scripts/ocr-real-smoke.ts + */ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { classifyInk } from '../src/ocr/gate.ts' +import { inkFixtures, isolatedSigma, pointPair, proseNote, proseParagraph, proseWord, sumGlyph } from '../src/ocr/comer/samples.ts' +import { recognizeStrokes } from '../src/ocr/comer/engine.ts' +import { vocabFromJson } from '../src/ocr/comer/source.ts' +import type { ComerSession, ComerTensor } from '../src/ocr/comer/decode.ts' +import { OCR_ACCEPT } from '../src/ocr/threshold.ts' + +const modelDir = 'public/models/comer' +const pins = JSON.parse(readFileSync('scripts/ocr-model-pins.json', 'utf8')) as Record +for (const [name, want] of Object.entries(pins)) { + const file = path.join(modelDir, name) + if (!existsSync(file)) throw new Error(`real OCR model missing: ${name}`) + const got = createHash('sha256').update(readFileSync(file)).digest('hex') + if (got !== want) throw new Error(`OCR model hash mismatch: ${name}`) +} + +const prose = proseNote() +assert.equal(classifyInk(prose), 'text', 'a baseline of letter-sized marks is not sent as a formula; the gate does not read Chinese or English') +assert.equal(classifyInk(proseWord()), 'text') +assert.equal(classifyInk(proseParagraph()), 'text') +assert.equal(classifyInk([]), 'formula', 'blank ink is not classified as prose') +const fixtures = inkFixtures(3) +for (const name of ['y-eq-2', 'y-eq-x', 'frac', 'a2-eq-b']) { + const sample = fixtures.find((item) => item.name === name) + assert.ok(sample, name) + assert.equal(classifyInk(sample.strokes), 'formula', `${name} is formula-like`) +} +const blank = await recognizeStrokes([], boom(), boom(), vocabPlaceholder(), 1) +assert.deepEqual(blank, []) +const noise = await recognizeStrokes( + [{ points: [{ x: 1, y: 1, t: 0 }, { x: 1.2, y: 1.1, t: 1 }] }], + boom(), + boom(), + vocabPlaceholder(), + 1, +) +assert.deepEqual(noise, []) +await assert.rejects( + () => recognizeStrokes(fixtures[0]!.strokes, failing('model failed'), failing('model failed'), vocabPlaceholder(), 1), + /model failed/, +) + +const ort = await import('onnxruntime-web/wasm') +ort.env.wasm.numThreads = 1 +ort.env.wasm.simd = true +ort.env.wasm.wasmPaths = pathToFileURL(`${path.resolve('node_modules/onnxruntime-web/dist')}/`).href +const vocab = vocabFromJson(JSON.parse(readFileSync(path.join(modelDir, 'vocab.json'), 'utf8'))) +const opts = { executionProviders: ['wasm' as const] } +const [encoder, decoder] = await Promise.all([ + ort.InferenceSession.create(readFileSync(path.join(modelDir, 'encoder_int8.onnx')), opts).then(wrap), + ort.InferenceSession.create(readFileSync(path.join(modelDir, 'decoder_int8.onnx')), opts).then(wrap), +]) + +let ran = 0 +for (const name of ['y-eq-2', 'y-eq-x', 'frac', 'a2-eq-b']) { + const sample = fixtures.find((item) => item.name === name)! + const hits = await recognizeStrokes(sample.strokes, encoder, decoder, vocab, 1) + const top = hits[0]?.text ?? '' + console.log(`synthetic ${name}: ${top || '(empty)'} conf=${hits[0]?.confidence ?? 0}`) + const expect = name === 'y-eq-x' ? /^[yY] = [xX]$/ : sample.expect + assert.match(top, expect, `${name} real model`) + assert.ok((hits[0]?.confidence ?? 0) >= OCR_ACCEPT, `${name} below ${OCR_ACCEPT}`) + assert.ok(!/1,\s*2,\s*2/.test(top), `${name} invented a triple`) + ran += 1 +} +const sumHits = await recognizeStrokes(sumGlyph(), encoder, decoder, vocab, 2) +console.log('synthetic sum', sumHits.map((h) => `${h.text} (${h.confidence.toFixed(2)})`).join(' | ') || '(empty)') +assert.match(sumHits[0]?.text ?? '', /^\\sum(?![A-Za-z])/) +const sigmaHits = await recognizeStrokes(isolatedSigma(), encoder, decoder, vocab, 2) +console.log('synthetic sigma', sigmaHits.map((h) => `${h.text} (${h.confidence.toFixed(2)})`).join(' | ') || '(empty)') +assert.equal(sigmaHits[0]?.text, 'sigma') +const pointHits = await recognizeStrokes(pointPair(), encoder, decoder, vocab, 2) +console.log('synthetic point', pointHits.map((h) => `${h.text} (${h.confidence.toFixed(2)})`).join(' | ') || '(empty)') +assert.match(pointHits[0]?.text ?? '', /^A\(2, 2\)$/) +assert.equal(ran, 4) +console.log('ocr-real-smoke ok (synthetic strokes; confidence is not accuracy; no handwriting scan in the repo)') + +function boom(): ComerSession { + return { async run() { throw new Error('blank or noise must not call the model') } } +} +function failing(message: string): ComerSession { + return { async run() { throw new Error(message) } } +} +function vocabPlaceholder() { + return vocabFromJson({ idx2word: { '3': 'y' }, special_tokens: { sos: 1, eos: 2, pad: 0 }, vocab_size: 4 }) +} + +function copyData(data: ArrayBufferView): Float32Array | Uint8Array | BigInt64Array { + if (data instanceof Float32Array) return Float32Array.from(data) + if (data instanceof BigInt64Array) return BigInt64Array.from(data) + if (data instanceof Uint8Array) return Uint8Array.from(data) + return new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)) +} + +function wrap(session: { run: (feeds: Record) => Promise void }>> }): ComerSession { + return { + async run(feeds) { + const mapped: Record void }> = {} + for (const [name, tensor] of Object.entries(feeds)) { + mapped[name] = new ort.Tensor(tensor.type, tensor.data as never, tensor.dims) + } + const out = await session.run(mapped) + for (const value of Object.values(mapped)) value.dispose?.() + const result: Record = {} + for (const [name, tensor] of Object.entries(out)) { + const data = copyData(tensor.data) + result[name] = { + type: data instanceof Float32Array ? 'float32' : data instanceof BigInt64Array ? 'int64' : 'bool', + data, + dims: [...tensor.dims], + dispose: () => tensor.dispose?.(), + } + } + return result + }, + } +} diff --git a/scripts/ocr-smoke.ts b/scripts/ocr-smoke.ts index 65f313c..4387e19 100644 --- a/scripts/ocr-smoke.ts +++ b/scripts/ocr-smoke.ts @@ -18,7 +18,8 @@ import { classifyInk, looksLikeIsolatedSigma, mathCues } from '../src/ocr/gate.t import { agreeConfidence, recognizeStrokes } from '../src/ocr/comer/engine.ts' import { OCR_ACCEPT } from '../src/ocr/threshold.ts' import { isInkMeaningful, preprocessStrokes, resamplePoints, type InkStroke } from '../src/ocr/comer/preprocess.ts' -import { arrowLetter, bowlU, closedZero, forkY, inkFixtures, isolatedSigma, proseNote, sigmaHook, sigmaLoop, sumBar, sumGlyph } from '../src/ocr/comer/samples.ts' +import { arrowLetter, bowlU, closedZero, forkY, inkFixtures, isolatedSigma, proseNote, proseParagraph, proseWord, sigmaHook, sigmaLoop, sumBar, sumGlyph } from '../src/ocr/comer/samples.ts' +import { recognizeMath } from '../src/ocr/comer/web.ts' import { looksLikeIsolatedSigma as sigmaGlyph, repairIsolatedSigma } from '../src/ocr/comer/sigma.ts' import { tokensToSource, vocabFromJson } from '../src/ocr/comer/source.ts' import { filterAbsurd, hasParenGeometry, recognizeInk } from '../src/ocr/inkShape.ts' @@ -569,6 +570,9 @@ function sumLikeSigma(): InkStroke[] { assert.equal(classifyInk(y2strokes), 'formula') assert.equal(classifyInk(arrowLetter()), 'formula') assert.equal(classifyInk(proseNote()), 'text', 'a line of letters is not a formula') +assert.equal(classifyInk(proseWord()), 'text', 'a short word is not a formula') +assert.equal(classifyInk(proseParagraph()), 'text', 'two lines of writing are not a formula') +assert.deepEqual(await recognizeMath(ink(proseNote().map((s) => s.points.map((p) => [p.x, p.y])))), []) assert.equal(classifyInk(sigmaLoop()), 'formula', 'an isolated σ still reaches the model') assert.equal(classifyInk(closedZero()), 'formula') assert.equal(classifyInk(sumLikeSigma()), 'formula', 'Σ with limits is a formula') @@ -578,6 +582,9 @@ assert.ok(mathCues(sumBar()).includes('sum'), 'barred Σ is a sum, not only an a assert.ok(!mathCues(sumBar()).includes('arrow')) assert.ok(mathCues(arrowLetter()).includes('arrow')) assert.equal(classifyInk(inkFixtures(3).find((s) => s.name === 'frac')!.strokes), 'formula') +const appSource = readFileSync('src/App.tsx', 'utf8') +assert.match(appSource, /autoOcr:\s*false/) +assert.match(appSource, /e\.code === 'KeyR'/) console.log('ocr gate ok', { prose: classifyInk(proseNote()), formula: classifyInk(y2strokes) }) assert.equal(tokensToSource(['\\ln', '(', 'x', ')']).replace(/\s+/g, ''), 'ln(x)') assert.equal(tokensToSource(['\\log', '_', '{', '2', '}', '(', 'x', ')']).replace(/\s+/g, ''), 'log_2(x)') diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 595e482..9278906 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ } ], "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost https://ipc.localhost; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost http: https: blob: data:; worker-src 'self' blob:; media-src 'self' blob:" + "csp": "default-src 'self' ipc: http://ipc.localhost https://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost http://127.0.0.1:* http://localhost:* https://localhost:* https: blob: data:; worker-src 'self' blob:; media-src 'self' blob:" } }, "bundle": { diff --git a/src/ai/storage.ts b/src/ai/storage.ts index 7dea08e..d4db5f0 100644 --- a/src/ai/storage.ts +++ b/src/ai/storage.ts @@ -4,12 +4,15 @@ export function loadAiConfig(): AiConfig { try { const raw = localStorage.getItem(AI_STORAGE_KEY) if (!raw) return emptyAiConfig() - return normalizeAiConfig(JSON.parse(raw)) + const cfg = normalizeAiConfig(JSON.parse(raw)) + if (cfg.apiKey) localStorage.setItem(AI_STORAGE_KEY, JSON.stringify({ ...cfg, apiKey: '' })) + return { ...cfg, apiKey: '' } } catch { return emptyAiConfig() } } export function saveAiConfig(cfg: AiConfig) { - localStorage.setItem(AI_STORAGE_KEY, JSON.stringify(normalizeAiConfig(cfg))) + const stored = normalizeAiConfig(cfg) + localStorage.setItem(AI_STORAGE_KEY, JSON.stringify({ ...stored, apiKey: '' })) } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index ebcc867..dd81e01 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -161,14 +161,14 @@ export const zh = { showGrid: '点阵纸', showGridDesc: '跟随缩放的浅色点阵', aiSection: 'AI 接口', - aiPrivacy: '请求只发往你填写的地址。密钥存在本机 localStorage(mathscrap.ai.v1),不会写进日志,也不会交给 MCP。', + aiPrivacy: '请求只发往你填写的地址。密钥只留在这一次打开的页面里,刷新后要再填,不会写进日志,也不会交给 MCP。', provider: '服务类型', providerOpenAI: 'OpenAI 兼容接口', providerAnthropic: 'Anthropic', providerLocal: '本地模型(Ollama 等)', model: '模型', apiKey: 'API Key', - apiKeyPh: '仅保存在本机', + apiKeyPh: '只在这次打开时有效', saveLocal: '保存到本机', savedLocal: '已保存在这台浏览器里(密钥只留在本机)', testing: '正在测试…', @@ -513,14 +513,14 @@ export const en: { [K in MessageKey]: string } = { showGrid: 'Dot grid', showGridDesc: 'A light grid that follows the zoom', aiSection: 'AI endpoint', - aiPrivacy: 'Requests go only to the address you type. The key stays in localStorage on this device (mathscrap.ai.v1). It is not written to logs and is not given to MCP.', + aiPrivacy: 'Requests go only to the address you type. The key stays in this open page and is cleared on reload. It is not written to logs and is not given to MCP.', provider: 'Service', providerOpenAI: 'OpenAI-compatible', providerAnthropic: 'Anthropic', providerLocal: 'Local model (Ollama and similar)', model: 'Model', apiKey: 'API key', - apiKeyPh: 'Stored on this device only', + apiKeyPh: 'Kept for this visit only', saveLocal: 'Save on this device', savedLocal: 'Saved in this browser (the key stays on this device)', testing: 'Testing…', diff --git a/src/ocr/comer/samples.ts b/src/ocr/comer/samples.ts index 8a273a6..aa4e247 100644 --- a/src/ocr/comer/samples.ts +++ b/src/ocr/comer/samples.ts @@ -123,6 +123,19 @@ export function inkFixtures(width = 3): { name: string; expect: RegExp; strokes: ] } +/** Three letter-sized marks. A short word, not a formula. Not real handwriting. */ +export function proseWord(width = 3): InkStroke[] { + return proseNote(width).slice(0, 3) +} + +/** + * Two baselines of letter-sized marks. Still a note. The shapes are not + * Chinese or English text, and this is not a reading of either language. + */ +export function proseParagraph(width = 3): InkStroke[] { + return [...proseNote(width), ...shiftInk(proseNote(width), 8, 48)] +} + /** Five letter-sized marks on one baseline: a note, not a formula. */ export function proseNote(width = 3): InkStroke[] { const out: InkStroke[] = [] diff --git a/src/ocr/gate.ts b/src/ocr/gate.ts index be2ae9b..d931e4a 100644 --- a/src/ocr/gate.ts +++ b/src/ocr/gate.ts @@ -2,9 +2,10 @@ import { findOverArrow, looksLikeSumBars } from './comer/bind.ts' import type { InkStroke, StrokePoint } from './comer/preprocess.ts' /** - * Round 1, before CoMER. Layout only: does this ink look like a formula, - * or like a line of ordinary writing? Native code can call this and skip - * `recognizeStrokes` when the answer is `text`. + * Round 1, before CoMER. This is a stroke-structure gate, not a reader of + * Chinese or English. A line of letter-sized marks with no math cue stays + * text and is not sent to the formula model. Equals, fractions, powers, + * roots-as-superscripts, parentheses, sums, sigmas, and arrows stay formulas. */ export type InkRole = 'formula' | 'text' @@ -26,26 +27,34 @@ export function mathCues(strokes: InkStroke[]): string[] { } /** - * Several letter-sized marks on one baseline, and none of the math cues. - * Notes and labels stay ink; they are not sent to the formula model. + * One or more lines of letter-sized marks, and none of the math cues. + * Three marks on a baseline are enough for a short word. A second line + * does not turn the note into a formula. This does not read the language. */ export function looksLikeProse(strokes: InkStroke[]): boolean { if (mathCues(strokes).length) return false const boxes = strokes .filter((s) => s.points.length >= 2) .map((s) => bounds(s.points)) - .sort((a, b) => a.x0 - b.x0) - if (boxes.length < 4) return false + .sort((a, b) => a.y1 - b.y1 || a.x0 - b.x0) + if (boxes.length < 3) return false const heights = boxes.map((b) => b.h).sort((a, b) => a - b) const med = heights[Math.floor(heights.length / 2)] if (med < 8) return false - const similar = boxes.every((b) => b.h > med * 0.45 && b.h < med * 2.2) - const bases = boxes.map((b) => b.y1).sort((a, b) => a - b) - const base = bases[Math.floor(bases.length / 2)] - const aligned = boxes.every((b) => Math.abs(b.y1 - base) < med * 0.55) - const stepped = boxes.every((b, i) => i === 0 || b.cx > boxes[i - 1].cx) - const all = bounds(strokes.flatMap((s) => s.points)) - return similar && aligned && stepped && all.w > all.h * 1.35 + const lines: ReturnType[][] = [] + for (const box of boxes) { + const row = lines.find((items) => Math.abs(items[0].y1 - box.y1) < med * 0.55) + if (row) row.push(box) + else lines.push([box]) + } + return lines.some((line) => { + const row = [...line].sort((a, b) => a.x0 - b.x0) + if (row.length < 3) return false + const similar = row.every((b) => b.h > med * 0.45 && b.h < med * 2.4 && b.w < med * 2.8) + const stepped = row.every((b, i) => i === 0 || b.cx > row[i - 1].cx + med * 0.15) + const span = row[row.length - 1].x1 - row[0].x0 + return similar && stepped && span > med * 2 + }) } /** diff --git a/src/wasm/pkg/package.json b/src/wasm/pkg/package.json index 15e56e7..954e73a 100644 --- a/src/wasm/pkg/package.json +++ b/src/wasm/pkg/package.json @@ -3,7 +3,7 @@ "type": "module", "description": "Fast math helpers (parse, sample, solve, geometry inference) for the MathScrap kraft notebook, compiled to WASM.", "version": "0.0.1", - "license": "MIT", + "license": "Apache-2.0", "files": [ "mathcore_bg.wasm", "mathcore.js", diff --git a/vite.config.ts b/vite.config.ts index ade106e..e636812 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,11 +52,11 @@ export default defineConfig({ }, }, server: { - host: '0.0.0.0', + host: process.env.MATHSCRAP_BIND === 'lan' ? '0.0.0.0' : '127.0.0.1', port: 47321, }, preview: { - host: '0.0.0.0', + host: process.env.MATHSCRAP_BIND === 'lan' ? '0.0.0.0' : '127.0.0.1', port: 47322, }, build: {