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(
Local daemon update required
++ Your local daemon uses an older read contract. Update Coven, restart + the daemon, then reload this dashboard. +
+ +