diff --git a/README.md b/README.md index e4b6c51..85c8055 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,26 @@ intentionally absent until their authority contracts are available. - pnpm 10 - a Coven daemon that exposes the Phase 1 memory reads +## Install and launch + +Install the local dashboard executable from npm: + +```bash +npm install -g @opencoven/coven-memory-dashboard +coven-memory-dashboard +``` + +The executable starts the packaged production server, validates the emitted +bare loopback URL, opens it with a shell-free platform browser command, and +stays attached to the server process. When the dashboard is installed as the +optional companion to the Coven npm CLI, `coven memory open` launches the same +entrypoint. + +The npm artifact contains the application build and runtime code only. It +contains no memory, daemon credential, launch token, or machine-specific build +root. Genuine data is requested at runtime from the local Coven daemon and +never becomes part of the package. + ## Development ```bash diff --git a/bin/coven-memory-dashboard.mjs b/bin/coven-memory-dashboard.mjs new file mode 100755 index 0000000..0c4f14c --- /dev/null +++ b/bin/coven-memory-dashboard.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { constants as osConstants } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const modulePath = fileURLToPath(import.meta.url); +const packageRoot = resolve(dirname(modulePath), ".."); +const serverPath = resolve(packageRoot, "server.ts"); +const launchPattern = /(?:^|\n)Coven Memory: ([^\r\n]+)/; + +export function parseLaunchUrl(output) { + const match = launchPattern.exec(output); + if (!match) { + return null; + } + + const emitted = match[1]; + const emittedLoopback = + /^http:\/\/(?:127\.0\.0\.1|\[::1\]):([1-9]\d{0,4})\/$/.exec( + emitted + ); + if (!emittedLoopback || Number(emittedLoopback[1]) > 65_535) { + throw new Error("refusing invalid launch URL"); + } + + let url; + try { + url = new URL(emitted); + } catch { + throw new Error("refusing invalid launch URL"); + } + const loopback = + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || url.hostname === "[::1]") && + !url.username && + !url.password && + url.pathname === "/" && + !url.search && + !url.hash; + if (!loopback) { + throw new Error("refusing invalid launch URL"); + } + return url; +} + +export function browserCommand(platform, url) { + if (platform === "darwin") { + return { program: "open", args: [url] }; + } + if (platform === "win32") { + return { + program: "rundll32.exe", + args: ["url.dll,FileProtocolHandler", url] + }; + } + return { program: "xdg-open", args: [url] }; +} + +export function isMainModule( + moduleFile, + argvFile, + canonicalize = realpathSync +) { + try { + return canonicalize(moduleFile) === canonicalize(argvFile); + } catch { + return false; + } +} + +export function signalExitCode(signal, signals = osConstants.signals) { + const signalNumber = signals[signal]; + return signalNumber === undefined ? 1 : 128 + signalNumber; +} + +function openBrowser(url) { + if (process.env.COVEN_MEMORY_NO_BROWSER === "1") { + return; + } + const spec = browserCommand(process.platform, url.href); + const opener = spawn(spec.program, spec.args, { + detached: true, + stdio: "ignore", + windowsHide: true + }); + opener.on("error", (error) => { + process.stderr.write( + `Could not open the browser automatically: ${error.message}\n` + ); + }); + opener.unref(); +} + +export function run() { + const child = spawn(process.execPath, ["--import", "tsx", serverPath], { + cwd: packageRoot, + env: { ...process.env, NODE_ENV: "production" }, + stdio: ["inherit", "pipe", "inherit"], + windowsHide: false + }); + let buffered = ""; + let opened = false; + let startupFailed = false; + + function failStartup(message) { + if (startupFailed) { + return; + } + startupFailed = true; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + child.kill("SIGTERM"); + } + + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + if (opened || startupFailed) { + return; + } + buffered += chunk.toString("utf8"); + if (buffered.length > 64 * 1024) { + failStartup("Dashboard startup output exceeded limit."); + return; + } + let launchUrl; + try { + launchUrl = parseLaunchUrl(buffered); + } catch (error) { + failStartup( + `Refused dashboard launch URL: ${ + error instanceof Error ? error.message : "invalid output" + }` + ); + return; + } + if (launchUrl) { + opened = true; + buffered = ""; + openBrowser(launchUrl); + } + }); + + const signalHandlers = new Map(); + for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + if (!child.killed) { + child.kill(signal); + } + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + child.on("error", (error) => { + process.stderr.write(`Failed to start Coven Memory: ${error.message}\n`); + process.exitCode = 1; + }); + + child.on("exit", (code, signal) => { + if (signal) { + if (!startupFailed) { + const handler = signalHandlers.get(signal); + if (handler) { + process.off(signal, handler); + } + if (process.platform === "win32") { + process.exit(signalExitCode(signal)); + } else { + process.kill(process.pid, signal); + } + } + return; + } + if (!opened && !startupFailed) { + process.stderr.write( + "Coven Memory exited before emitting a launch URL.\n" + ); + process.exitCode = 1; + return; + } + if (!startupFailed) { + process.exitCode = code ?? 1; + } + }); +} + +if (process.argv[1] && isMainModule(modulePath, process.argv[1])) { + run(); +} diff --git a/package.json b/package.json index db668dd..92384a8 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,49 @@ { "name": "@opencoven/coven-memory-dashboard", "version": "0.1.0", - "private": true, + "private": false, "type": "module", "engines": { "node": ">=24.0.0" }, "packageManager": "pnpm@10.34.0", + "bin": { + "coven-memory-dashboard": "bin/coven-memory-dashboard.mjs" + }, + "files": [ + ".next/BUILD_ID", + ".next/app-path-routes-manifest.json", + ".next/build-manifest.json", + ".next/package.json", + ".next/prerender-manifest.json", + ".next/required-server-files.json", + ".next/routes-manifest.json", + ".next/server", + ".next/static", + "bin/coven-memory-dashboard.mjs", + "src/lib/memory-types.ts", + "src/server/api-response.ts", + "src/server/daemon-transport.ts", + "src/server/listen-options.ts", + "src/server/local-transport.ts", + "src/server/memory-contract.ts", + "src/server/memory-gateway.ts", + "src/server/request-guard.ts", + "src/server/runtime.ts", + "src/server/security-headers.ts", + "README.md", + "next-env.d.ts", + "next.config.ts", + "server.ts", + "tsconfig.json" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "dev": "tsx server.ts", "build": "next build", + "build:package": "pnpm build && node scripts/sanitize-build-artifact.mjs", "start": "NODE_ENV=production tsx server.ts", "lint": "eslint . --max-warnings=0", "typecheck": "tsc --noEmit", @@ -19,6 +53,8 @@ "audit:prod": "pnpm audit --prod --audit-level high", "fake-daemon": "node scripts/fake-memory-daemon.mjs", "test:smoke": "node scripts/smoke-dashboard.mjs", + "test:package": "pnpm build:package && node --test scripts/dashboard-bin.test.mjs scripts/sanitize-build-artifact.test.mjs && node scripts/package-contents-test.mjs", + "prepack": "pnpm build:package", "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke && ./scripts/guard-scan.sh" }, "dependencies": { @@ -27,6 +63,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-markdown": "10.1.0", + "tsx": "4.20.6", "zod": "4.4.3" }, "devDependencies": { @@ -39,7 +76,6 @@ "eslint-config-next": "16.2.11", "jsdom": "^27.4.0", "playwright": "1.62.0", - "tsx": "^4.20.6", "typescript": "6.0.3", "vitest": "4.1.9" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd9992c..6a1ee33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: react-markdown: specifier: 10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + tsx: + specifier: 4.20.6 + version: 4.20.6 zod: specifier: 4.4.3 version: 4.4.3 @@ -58,15 +61,12 @@ importers: playwright: specifier: 1.62.0 version: 1.62.0 - tsx: - specifier: ^4.20.6 - version: 4.23.1 typescript: specifier: 6.0.3 version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@types/node@24.13.2)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1)) + version: 4.1.9(@types/node@24.13.2)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6)) packages: @@ -213,156 +213,312 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1393,6 +1549,11 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -2596,8 +2757,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} hasBin: true @@ -3035,81 +3196,159 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -3680,13 +3919,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1))': + '@vitest/mocker@4.1.9(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1) + vite: 8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6) '@vitest/pretty-format@4.1.9': dependencies: @@ -4125,6 +4364,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -4153,6 +4421,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.1 '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + optional: true escalade@3.2.0: {} @@ -4164,7 +4433,7 @@ snapshots: eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4))(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) eslint-plugin-react: 7.37.5(eslint@9.39.4) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4) @@ -4197,7 +4466,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) transitivePeerDependencies: - supports-color @@ -4212,7 +4481,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5677,9 +5946,10 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.20.6: dependencies: - esbuild: 0.28.1 + esbuild: 0.25.12 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 @@ -5822,7 +6092,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1): + vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -5833,12 +6103,12 @@ snapshots: '@types/node': 24.13.2 esbuild: 0.28.1 fsevents: 2.3.3 - tsx: 4.23.1 + tsx: 4.20.6 - vitest@4.1.9(@types/node@24.13.2)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1)): + vitest@4.1.9(@types/node@24.13.2)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1)) + '@vitest/mocker': 4.1.9(vite@8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -5855,7 +6125,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.23.1) + vite: 8.1.5(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.20.6) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 diff --git a/scripts/dashboard-bin.test.mjs b/scripts/dashboard-bin.test.mjs new file mode 100644 index 0000000..6df6479 --- /dev/null +++ b/scripts/dashboard-bin.test.mjs @@ -0,0 +1,216 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; +import { + browserCommand, + isMainModule, + parseLaunchUrl, + signalExitCode +} from "../bin/coven-memory-dashboard.mjs"; + +const packageRoot = fileURLToPath(new URL("../", import.meta.url)); +const dashboardEntry = fileURLToPath( + new URL("../bin/coven-memory-dashboard.mjs", import.meta.url) +); + +async function freePort() { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const port = address.port; + server.close(); + await once(server, "close"); + return port; +} + +function waitForLaunch(child, stderr, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + let stdout = ""; + const timeout = setTimeout(() => { + cleanup(); + reject( + new Error( + `dashboard did not emit a launch URL\nstdout:\n${stdout}\nstderr:\n${stderr()}` + ) + ); + }, timeoutMs); + const onData = (chunk) => { + stdout += chunk.toString("utf8"); + if (/Coven Memory: http:\/\/127\.0\.0\.1:\d+\//.test(stdout)) { + cleanup(); + resolve(); + } + }; + const onExit = (code, signal) => { + cleanup(); + reject( + new Error( + `dashboard exited before launch: code=${String(code)} signal=${String( + signal + )}\nstdout:\n${stdout}\nstderr:\n${stderr()}` + ) + ); + }; + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onData); + child.off("exit", onExit); + }; + child.stdout.on("data", onData); + child.once("exit", onExit); + }); +} + +test("accepts only an emitted loopback launch URL", () => { + const parsed = parseLaunchUrl( + "ready\nCoven Memory: http://127.0.0.1:3737/\n" + ); + assert.equal(parsed?.href, "http://127.0.0.1:3737/"); + assert.equal( + parseLaunchUrl("Coven Memory: http://[::1]:3737/\n")?.href, + "http://[::1]:3737/" + ); + assert.equal( + parseLaunchUrl("Coven Memory: http://127.0.0.1:80/\n")?.href, + "http://127.0.0.1/" + ); + assert.equal( + parseLaunchUrl("Coven Memory: http://127.0.0.1:65535/\n")?.href, + "http://127.0.0.1:65535/" + ); + for (const rejected of [ + "https://memory.example/", + "http://127.0.0.1/", + "http://127.0.0.1:3737/path", + "http://127.0.0.1:3737/?next=remote", + "http://127.0.0.1:3737/#fragment", + "http://user@127.0.0.1:3737/", + "not-a-url" + ]) { + assert.throws( + () => parseLaunchUrl(`Coven Memory: ${rejected}\n`), + /refusing invalid launch URL/ + ); + } +}); + +test("uses shell-free platform browser commands", () => { + const url = "http://127.0.0.1:3737/"; + assert.deepEqual(browserCommand("darwin", url), { + program: "open", + args: [url] + }); + assert.deepEqual(browserCommand("linux", url), { + program: "xdg-open", + args: [url] + }); + assert.deepEqual(browserCommand("win32", url), { + program: "rundll32.exe", + args: ["url.dll,FileProtocolHandler", url] + }); +}); + +test("uses conventional signal exit codes on Windows", () => { + assert.equal(signalExitCode("SIGINT", { SIGINT: 2 }), 130); + assert.equal(signalExitCode("SIGTERM", { SIGTERM: 15 }), 143); + assert.equal(signalExitCode("UNKNOWN", {}), 1); +}); + +test("recognizes the executable through a symlinked parent directory", () => { + const canonicalize = (path) => + path.replace(/^\/var\//, "/private/var/"); + + assert.equal( + isMainModule( + "/private/var/package/bin/coven-memory-dashboard.mjs", + "/var/package/bin/coven-memory-dashboard.mjs", + canonicalize + ), + true + ); +}); + +test( + "preserves SIGINT and SIGTERM termination during startup", + { skip: process.platform === "win32", timeout: 30_000 }, + async () => { + for (const signal of ["SIGINT", "SIGTERM"]) { + const port = await freePort(); + let stderr = ""; + const child = spawn(process.execPath, [dashboardEntry], { + cwd: packageRoot, + env: { + ...process.env, + COVEN_MEMORY_NO_BROWSER: "1", + HOST: "127.0.0.1", + NODE_ENV: "production", + PORT: String(port) + }, + stdio: ["ignore", "pipe", "pipe"] + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + const exited = once(child, "exit"); + assert.equal(child.kill(signal), true); + const [code, exitSignal] = await exited; + assert.equal(code, null, `${signal} became exit ${String(code)}: ${stderr}`); + assert.equal(exitSignal, signal, stderr); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await once(child, "exit"); + } + } + } + } +); + +test( + "preserves SIGINT and SIGTERM termination after forwarding to the server", + { skip: process.platform === "win32", timeout: 90_000 }, + async () => { + for (const signal of ["SIGINT", "SIGTERM"]) { + const port = await freePort(); + let stderr = ""; + const child = spawn(process.execPath, [dashboardEntry], { + cwd: packageRoot, + env: { + ...process.env, + COVEN_MEMORY_NO_BROWSER: "1", + HOST: "127.0.0.1", + NODE_ENV: "production", + PORT: String(port) + }, + stdio: ["ignore", "pipe", "pipe"] + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + try { + await waitForLaunch(child, () => stderr); + const exited = once(child, "exit"); + assert.equal(child.kill(signal), true); + const [code, exitSignal] = await exited; + assert.equal(code, null, `${signal} became exit ${String(code)}: ${stderr}`); + assert.equal(exitSignal, signal, stderr); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await once(child, "exit"); + } + } + } + } +); diff --git a/scripts/package-contents-test.mjs b/scripts/package-contents-test.mjs new file mode 100644 index 0000000..e5bd6c9 --- /dev/null +++ b/scripts/package-contents-test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +const output = execFileSync( + "npm", + ["pack", "--dry-run", "--json", "--ignore-scripts"], + { encoding: "utf8" } +); +const [manifest] = JSON.parse(output); +const paths = manifest.files.map((file) => file.path); + +for (const required of [ + "bin/coven-memory-dashboard.mjs", + "server.ts", + ".next/BUILD_ID", + ".next/server", + ".next/static", + "src/lib/memory-types.ts", + "src/server/local-transport.ts", + "src/server/runtime.ts" +]) { + assert( + paths.some((path) => path === required || path.startsWith(`${required}/`)), + `missing packaged path: ${required}` + ); +} + +const allowed = [ + /^package\.json$/, + /^README\.md$/, + /^bin\/coven-memory-dashboard\.mjs$/, + /^server\.ts$/, + /^next-env\.d\.ts$/, + /^next\.config\.ts$/, + /^tsconfig\.json$/, + /^\.next\/(?:BUILD_ID|app-path-routes-manifest\.json|build-manifest\.json|package\.json|prerender-manifest\.json|required-server-files\.json|routes-manifest\.json)$/, + /^\.next\/(?:server|static)\//, + /^src\/lib\/memory-types\.ts$/, + /^src\/server\/(?:api-response|daemon-transport|listen-options|local-transport|memory-contract|memory-gateway|request-guard|runtime|security-headers)\.ts$/ +]; + +for (const path of paths) { + assert( + allowed.some((pattern) => pattern.test(path)), + `unexpected packaged path: ${path}` + ); +} + +process.stdout.write("Dashboard package contents are restricted.\n"); diff --git a/scripts/sanitize-build-artifact.mjs b/scripts/sanitize-build-artifact.mjs new file mode 100644 index 0000000..f07ddfc --- /dev/null +++ b/scripts/sanitize-build-artifact.mjs @@ -0,0 +1,178 @@ +import { + lstat, + readFile, + readdir, + realpath, + writeFile +} from "node:fs/promises"; +import { + dirname, + isAbsolute, + relative, + resolve, + sep +} from "node:path"; +import { fileURLToPath } from "node:url"; + +const modulePath = fileURLToPath(import.meta.url); +const packageRoot = resolve(dirname(modulePath), ".."); +const requiredRuntimePaths = [ + ".next/BUILD_ID", + ".next/app-path-routes-manifest.json", + ".next/build-manifest.json", + ".next/package.json", + ".next/prerender-manifest.json", + ".next/required-server-files.json", + ".next/routes-manifest.json", + ".next/server", + ".next/static", + "bin/coven-memory-dashboard.mjs", + "server.ts", + "next.config.ts", + "src/lib/memory-types.ts", + "src/server/api-response.ts", + "src/server/daemon-transport.ts", + "src/server/listen-options.ts", + "src/server/local-transport.ts", + "src/server/memory-contract.ts", + "src/server/memory-gateway.ts", + "src/server/request-guard.ts", + "src/server/runtime.ts", + "src/server/security-headers.ts" +]; +const policies = [ + { + name: "absolute-home-path", + pattern: + /(?:\/Users\/|\/home\/)(?!(?:example|placeholder|you|USERNAME|\$USER)(?:\/|$))[A-Za-z0-9._-]+/ + }, + { + name: "windows-user-profile-path", + pattern: + /\b[A-Za-z]:[\\/]+Users[\\/]+(?!(?:example|placeholder|you|USERNAME)(?:[\\/]|$))[^\\/\s"']+/ + }, + { + name: "private-channel-identifier", + pattern: + /(?:agent:[a-z0-9_-]+:(?:telegram|imessage|discord|whatsapp|signal|webchat):[a-z]+:[^\s"']+|(?:telegram|imessage|discord|whatsapp|signal):(?:direct:)?\d{6,})/i + }, + { + name: "private-key-material", + pattern: + /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/ + }, + { + name: "genuine-fixture-sentinel", + pattern: + /\b(?:(?:COVEN|OPENCLAW)[-_])?(?:GENUINE|REAL)[-_](?:MEMORY[-_])?(?:FIXTURE|SENTINEL)\b/i + } +]; + +function objectValue(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function sanitizeRequiredServerFiles(value) { + const sanitized = structuredClone(value); + if (!objectValue(sanitized) || !objectValue(sanitized.config)) { + throw new Error("required server files manifest has no config"); + } + + delete sanitized.appDir; + delete sanitized.config.outputFileTracingRoot; + if (objectValue(sanitized.config.turbopack)) { + delete sanitized.config.turbopack.root; + if (Object.keys(sanitized.config.turbopack).length === 0) { + delete sanitized.config.turbopack; + } + } + return sanitized; +} + +function displayPath(path) { + return relative(packageRoot, path).split(sep).join("/"); +} + +function policyFailure(path, policy) { + throw new Error( + `build artifact policy ${policy} failed: ${displayPath(path)}` + ); +} + +function isContained(root, candidate) { + const pathFromRoot = relative(root, candidate); + return ( + pathFromRoot === "" || + (!isAbsolute(pathFromRoot) && + pathFromRoot !== ".." && + !pathFromRoot.startsWith(`..${sep}`)) + ); +} + +async function scanFile(path) { + const contents = (await readFile(path)).toString("utf8"); + for (const policy of policies) { + if (policy.pattern.test(contents)) { + policyFailure(path, policy.name); + } + } +} + +async function scanEntry(path, realRoot) { + let metadata; + try { + metadata = await lstat(path); + } catch { + policyFailure(path, "required-runtime-path"); + } + if (metadata.isSymbolicLink()) { + policyFailure(path, "runtime-symlink"); + } + + let resolved; + try { + resolved = await realpath(path); + } catch { + policyFailure(path, "runtime-realpath"); + } + if (!isContained(realRoot, resolved)) { + policyFailure(path, "runtime-path-escape"); + } + + if (metadata.isDirectory()) { + const children = await readdir(path); + children.sort(); + for (const child of children) { + await scanEntry(resolve(path, child), realRoot); + } + return; + } + if (!metadata.isFile()) { + policyFailure(path, "runtime-file-type"); + } + await scanFile(path); +} + +async function sanitizeBuildArtifact() { + const manifestPath = resolve( + packageRoot, + ".next/required-server-files.json" + ); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const sanitized = sanitizeRequiredServerFiles(manifest); + await writeFile( + manifestPath, + `${JSON.stringify(sanitized, null, 2)}\n`, + "utf8" + ); + + const realRoot = await realpath(packageRoot); + for (const runtimePath of requiredRuntimePaths) { + await scanEntry(resolve(packageRoot, runtimePath), realRoot); + } + process.stdout.write("Dashboard build artifact sanitized.\n"); +} + +if (process.argv[1] && resolve(process.argv[1]) === modulePath) { + await sanitizeBuildArtifact(); +} diff --git a/scripts/sanitize-build-artifact.test.mjs b/scripts/sanitize-build-artifact.test.mjs new file mode 100644 index 0000000..162ffc3 --- /dev/null +++ b/scripts/sanitize-build-artifact.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeRequiredServerFiles } from "./sanitize-build-artifact.mjs"; + +test("removes build-only absolute roots from the runtime manifest", () => { + const sanitized = sanitizeRequiredServerFiles({ + version: 1, + appDir: "/Users/example/private-checkout", // gitleaks:allow — synthetic sanitizer fixture + config: { + outputFileTracingRoot: "/Users/example/private-checkout", // gitleaks:allow — synthetic sanitizer fixture + turbopack: { + root: "/Users/example/private-checkout" // gitleaks:allow — synthetic sanitizer fixture + }, + distDir: ".next" + }, + files: [".next/BUILD_ID"] + }); + + assert.equal(sanitized.appDir, undefined); + assert.equal(sanitized.config.outputFileTracingRoot, undefined); + assert.equal(sanitized.config.turbopack, undefined); + assert.equal(sanitized.config.distDir, ".next"); + assert.doesNotMatch(JSON.stringify(sanitized), /\/Users\/|\/home\//); +}); diff --git a/scripts/smoke-dashboard.mjs b/scripts/smoke-dashboard.mjs index 56fea96..16a91ab 100644 --- a/scripts/smoke-dashboard.mjs +++ b/scripts/smoke-dashboard.mjs @@ -182,17 +182,24 @@ try { "stock Next.js hosting did not fail closed" ); - const dashboard = start( - process.execPath, - ["--import", "tsx", "server.ts"], - { + const installedDashboardEntry = + process.env.COVEN_MEMORY_SMOKE_DASHBOARD_ENTRY; + const dashboard = installedDashboardEntry + ? start(process.execPath, [installedDashboardEntry], { + NODE_ENV: "production", + HOST: host, + PORT: String(dashboardPort), + COVEN_MEMORY_NO_BROWSER: "1", + COVEN_DAEMON_URL: fakeOrigin, + COVEN_DAEMON_SOCKET: absentSocketPath + }) + : start(process.execPath, ["--import", "tsx", "server.ts"], { NODE_ENV: "production", HOST: host, PORT: String(dashboardPort), COVEN_DAEMON_URL: fakeOrigin, COVEN_DAEMON_SOCKET: absentSocketPath - } - ); + }); const launched = await launchUrl(dashboard); invariant(launched.origin === dashboardOrigin, "unexpected dashboard origin"); invariant(launched.hash === "", "dashboard URL contained a launch fragment"); diff --git a/src/app/api/memory/routes.test.ts b/src/app/api/memory/routes.test.ts index 970fcee..45e09c6 100644 --- a/src/app/api/memory/routes.test.ts +++ b/src/app/api/memory/routes.test.ts @@ -165,6 +165,23 @@ describe("memory API routes", () => { }); }); + it("maps daemon incompatibility to an update-required response", async () => { + useRuntime({ + overview: vi + .fn() + .mockRejectedValue(new MemoryGatewayError("daemon_incompatible")) + }); + + const response = await overview(request("/api/memory/overview")); + + expect(response.status).toBe(426); + expect(await response.json()).toEqual({ + ok: false, + code: "daemon_update_required" + }); + expect(response.headers.get("cache-control")).toContain("no-store"); + }); + it("returns safe diagnostic codes for invalid IDs and invalid daemon data", async () => { useRuntime({ detail: vi.fn().mockRejectedValue(new MemoryGatewayError("invalid_id")) diff --git a/src/app/globals.css b/src/app/globals.css index 921777c..451adbe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -289,6 +289,20 @@ select { flex: none; } +.memory-update-required { + max-width: 44rem; + margin: var(--cv-space-8) auto; + padding: var(--cv-space-8); +} + +.memory-update-required h2 { + margin-block: var(--cv-space-2) var(--cv-space-3); +} + +.memory-update-required .cv-action { + margin-top: var(--cv-space-4); +} + .memory-workspace { display: grid; min-height: 0; diff --git a/src/features/memory/memory-dashboard.test.tsx b/src/features/memory/memory-dashboard.test.tsx index 199eac7..462551b 100644 --- a/src/features/memory/memory-dashboard.test.tsx +++ b/src/features/memory/memory-dashboard.test.tsx @@ -487,6 +487,28 @@ describe("MemoryDashboard", () => { ).not.toBeInTheDocument(); }); + it("replaces memory panes with an update-required gate", async () => { + installMatchMedia(false); + installApi({ + list: "daemon_update_required", + listStatus: 426, + overview: "daemon_update_required", + overviewStatus: 426 + }); + + render(); + + expect( + await screen.findByRole("heading", { + name: "Update Coven to open this version of Memory" + }) + ).toBeVisible(); + expect(screen.queryByText("No memories yet")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Reveal memory content" }) + ).not.toBeInTheDocument(); + }); + it("exposes a state-aware daemon status when narrow copy is visually compact", async () => { installMatchMedia(true); installApi({ list: "memory_unavailable", listStatus: 503 }); diff --git a/src/features/memory/memory-dashboard.tsx b/src/features/memory/memory-dashboard.tsx index 7d2196b..e43bafb 100644 --- a/src/features/memory/memory-dashboard.tsx +++ b/src/features/memory/memory-dashboard.tsx @@ -20,6 +20,7 @@ import { import { MemoryList, type MemoryListHandle } from "./memory-list"; import { MemoryDiagnostics, MemoryOverview } from "./memory-overview"; import { MemoryReader } from "./memory-reader"; +import { MemoryUpdateRequired } from "./memory-update-required"; import { useMemoryDashboard } from "./use-memory-dashboard"; const NARROW_LAYOUT_QUERY = "(max-width: 56rem)"; @@ -199,75 +200,81 @@ export function MemoryDashboard() { ) : null} -
- -
- -
- +
-
- { - cancelFocusReturn(); - readerFocusIntentRef.current = isNarrowLayout; - dashboard.setSelectedId(id); - setNarrowPane("reader"); - }} - onRetry={dashboard.reload} - onClearFilters={clearFilters} - /> -
+
+ -
- { - readerFocusIntentRef.current = false; - cancelFocusReturn(); - setNarrowPane("list"); - focusReturnFrameRef.current = - window.requestAnimationFrame(() => { - focusReturnFrameRef.current = null; - listRef.current?.focusSelected(); - }); - }} - onRetry={dashboard.retryDetail} - /> -
-
+
+ { + cancelFocusReturn(); + readerFocusIntentRef.current = isNarrowLayout; + dashboard.setSelectedId(id); + setNarrowPane("reader"); + }} + onRetry={dashboard.reload} + onClearFilters={clearFilters} + /> +
+ +
+ { + readerFocusIntentRef.current = false; + cancelFocusReturn(); + setNarrowPane("list"); + focusReturnFrameRef.current = + window.requestAnimationFrame(() => { + focusReturnFrameRef.current = null; + listRef.current?.focusSelected(); + }); + }} + onRetry={dashboard.retryDetail} + /> +
+ + + )} ); } diff --git a/src/features/memory/memory-update-required.tsx b/src/features/memory/memory-update-required.tsx new file mode 100644 index 0000000..4ecab4c --- /dev/null +++ b/src/features/memory/memory-update-required.tsx @@ -0,0 +1,30 @@ +type MemoryUpdateRequiredProps = { + onRetry: () => void; +}; + +export function MemoryUpdateRequired({ + onRetry +}: MemoryUpdateRequiredProps) { + return ( +
+

Local daemon update required

+

+ Update Coven to open this version of Memory +

+

+ Your local daemon uses an older read contract. Update Coven, restart + the daemon, then reload this dashboard. +

+ +
+ ); +} diff --git a/src/features/memory/use-memory-dashboard.test.tsx b/src/features/memory/use-memory-dashboard.test.tsx index 54b2a67..7d98046 100644 --- a/src/features/memory/use-memory-dashboard.test.tsx +++ b/src/features/memory/use-memory-dashboard.test.tsx @@ -100,6 +100,17 @@ describe("useMemoryDashboard", () => { vi.unstubAllGlobals(); }); + it("derives updateRequired from a daemon compatibility response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => json("daemon_update_required", 426)) + ); + + const { result } = renderHook(() => useMemoryDashboard()); + + await waitFor(() => expect(result.current.updateRequired).toBe(true)); + }); + it("keeps list failure distinct from a successful empty list", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/memory/use-memory-dashboard.ts b/src/features/memory/use-memory-dashboard.ts index 04962ce..97a14b5 100644 --- a/src/features/memory/use-memory-dashboard.ts +++ b/src/features/memory/use-memory-dashboard.ts @@ -453,11 +453,17 @@ export function useMemoryDashboard( setDetailVersion((value) => value + 1); } }, []); + const updateRequired = + (overview.status === "error" && + overview.error === "daemon_update_required") || + (list.status === "error" && + list.error === "daemon_update_required"); return { overview, list, detail, + updateRequired, selectedId, setSelectedId: selectMemory, filters, diff --git a/src/server/api-response.ts b/src/server/api-response.ts index a41bee0..3ff82cf 100644 --- a/src/server/api-response.ts +++ b/src/server/api-response.ts @@ -89,6 +89,12 @@ export async function guardedMemoryJson( { status: 400 } ); } + if (error.code === "daemon_incompatible") { + return jsonNoStore( + { ok: false, code: "daemon_update_required" }, + { status: 426 } + ); + } if (error.code === "invalid_payload") { return jsonNoStore( { ok: false, code: "invalid_daemon_payload" }, diff --git a/src/server/memory-contract.ts b/src/server/memory-contract.ts index 02ea77d..c473263 100644 --- a/src/server/memory-contract.ts +++ b/src/server/memory-contract.ts @@ -56,6 +56,17 @@ export const memoryListSchema = z.array( }) ); +export const legacyMemoryListSchema = z.array( + z.strictObject({ + id: z.string().min(1).max(1_024), + familiar_id: z.string().min(1).max(256), + title: z.string().min(1).max(1_024), + path: relativeMemoryPath, + updated_at: z.string().min(1).max(128), + excerpt: shortText + }) +); + export const memoryOverviewSchema = z .strictObject({ generated_at: isoTimestamp, diff --git a/src/server/memory-gateway.test.ts b/src/server/memory-gateway.test.ts index 5caa7e9..e0fdfe6 100644 --- a/src/server/memory-gateway.test.ts +++ b/src/server/memory-gateway.test.ts @@ -206,6 +206,30 @@ describe("memory gateway", () => { expect(transport.get).not.toHaveBeenCalled(); }); + it("classifies the legacy list contract as an update requirement", async () => { + const legacy = transportResponse(200, [ + { + id: "sage-notes", + familiar_id: "sage", + title: "notes", + path: "sage/notes.md", + updated_at: "4m ago", + excerpt: "Synthetic legacy excerpt." + } + ]); + + await expect(createMemoryGateway(legacy).list()).rejects.toMatchObject({ + code: "daemon_incompatible" + }); + }); + + it("classifies a missing overview route as an update requirement", async () => { + await expect( + createMemoryGateway(transportResponse(404, { error: "not_found" })) + .overview() + ).rejects.toMatchObject({ code: "daemon_incompatible" }); + }); + it("collapses invalid JSON and schema failures into a safe error code", async () => { for (const value of ["local path and private body", { unexpected: true }]) { const error = await createMemoryGateway( diff --git a/src/server/memory-gateway.ts b/src/server/memory-gateway.ts index b7dd5ce..8ecd9d7 100644 --- a/src/server/memory-gateway.ts +++ b/src/server/memory-gateway.ts @@ -6,6 +6,7 @@ import type { MemoryVerificationState } from "@/lib/memory-types"; import { + legacyMemoryListSchema, memoryDetailSchema, memoryListSchema, memoryOverviewSchema @@ -19,6 +20,7 @@ type Transport = { type MemoryGatewayErrorCode = | "invalid_id" | "invalid_payload" + | "daemon_incompatible" | "daemon_status"; const COVEN_ORIGIN_SOURCE = { @@ -36,25 +38,41 @@ export class MemoryGatewayError extends Error { } } -function parsed(schema: z.ZodType, body: string): T { +function decoded(body: string): unknown { try { - const result = schema.safeParse(JSON.parse(body)); - if (!result.success) { - throw new MemoryGatewayError("invalid_payload"); - } - return result.data; - } catch (error) { - if (error instanceof MemoryGatewayError) { - throw error; - } + return JSON.parse(body); + } catch { throw new MemoryGatewayError("invalid_payload"); } } -function requireSuccess(response: DaemonResponse) { - if (response.status !== 200) { - throw new MemoryGatewayError("daemon_status", response.status); +function parsed( + schema: z.ZodType, + body: string, + incompatibleSchema?: z.ZodType +): T { + const value = decoded(body); + const result = schema.safeParse(value); + if (result.success) { + return result.data; + } + if (incompatibleSchema?.safeParse(value).success) { + throw new MemoryGatewayError("daemon_incompatible"); + } + throw new MemoryGatewayError("invalid_payload"); +} + +function requireSuccess( + response: DaemonResponse, + options: { missingMeansIncompatible?: boolean } = {} +) { + if (response.status === 200) { + return; + } + if (response.status === 404 && options.missingMeansIncompatible) { + throw new MemoryGatewayError("daemon_incompatible"); } + throw new MemoryGatewayError("daemon_status", response.status); } function browserVerificationState( @@ -68,27 +86,31 @@ export function createMemoryGateway(transport: Transport) { async list(): Promise { const response = await transport.get("/api/v1/memory"); requireSuccess(response); - return parsed(memoryListSchema, response.body).map((entry) => ({ - id: entry.id, - familiarId: entry.familiar_id, - title: entry.title, - updatedAt: entry.updated_at_iso, - relativeUpdatedAt: entry.updated_at, - excerpt: entry.excerpt, - source: entry.source ?? COVEN_ORIGIN_SOURCE, - privacy: { - classification: entry.privacy_classification, - revealRequired: entry.reveal_required - }, - verification: { - state: browserVerificationState(entry.verification_state) - } - })); + return parsed( + memoryListSchema, + response.body, + legacyMemoryListSchema + ).map((entry) => ({ + id: entry.id, + familiarId: entry.familiar_id, + title: entry.title, + updatedAt: entry.updated_at_iso, + relativeUpdatedAt: entry.updated_at, + excerpt: entry.excerpt, + source: entry.source ?? COVEN_ORIGIN_SOURCE, + privacy: { + classification: entry.privacy_classification, + revealRequired: entry.reveal_required + }, + verification: { + state: browserVerificationState(entry.verification_state) + } + })); }, async overview(): Promise { const response = await transport.get("/api/v1/memory/overview"); - requireSuccess(response); + requireSuccess(response, { missingMeansIncompatible: true }); const value = parsed(memoryOverviewSchema, response.body); return { generatedAt: value.generated_at, diff --git a/vitest.config.ts b/vitest.config.ts index 44acc7a..4fbe17b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,11 @@ export default defineConfig({ globals: true, setupFiles: ["./vitest.setup.ts"], restoreMocks: true, - exclude: [...configDefaults.exclude, "**/.worktrees/**"] + exclude: [ + ...configDefaults.exclude, + "**/.worktrees/**", + "scripts/dashboard-bin.test.mjs", + "scripts/sanitize-build-artifact.test.mjs" + ] } });