diff --git a/.github/workflows/moss-vscode-ci.yml b/.github/workflows/moss-vscode-ci.yml index 104c9398..9acb3bf6 100644 --- a/.github/workflows/moss-vscode-ci.yml +++ b/.github/workflows/moss-vscode-ci.yml @@ -44,11 +44,26 @@ jobs: - name: Build run: npm run build + # Worker-survival E2E + supervisor negative control. Runs on every OS in + # the matrix and EXECUTES the real installed native addon for that runner: + # macOS arm64 (macos-latest), Linux x64 (ubuntu-latest), Windows x64 + # (windows-latest). GitHub offers no hosted Darwin x64 or Linux arm64 + # runners, so those two shipped binaries are not executed here; they are + # bundled and statically version/hook-checked by verify-package. Hermetic: + # loopback auth stub, no model download, no customer data (MOS-166). + - name: Regression tests (worker survival + supervisor abort/restart) + run: npm test + - name: Package VSIX (Ubuntu only) if: matrix.os == 'ubuntu-latest' run: npm run package - - name: Verify VSIX contents (Ubuntu only) + # VSIX integrity gate: asserts the bundled wrapper/core/platform versions + # are the fixed pinned set, that the lockfile has not dropped below the + # fix, that no test source / fixtures / hidden test-hook exports leaked, + # and executes the extracted worker against the VSIX's own bundled .node. + # Packaging fails if the boundary regresses or the lock drops (MOS-166). + - name: Verify VSIX integrity (Ubuntu only) if: matrix.os == 'ubuntu-latest' run: npm run verify-package diff --git a/AGENTS.md b/AGENTS.md index a4291351..939e31e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,7 @@ asks for an experimental landing spot. | `apps/moss-bun/` | Production Bun + Moss application | | `apps/moss-llamaindex/` | Full-stack PDF → LlamaIndex + Liteparse + Moss semantic search demo | | `apps/next-js/` | Next.js 16 browser-based semantic search UI using `@moss-dev/moss-web`; reference UI for semantic search | +| `apps/moss-vscode/` | VS Code extension. Runs `@moss-dev/moss` in a forked Node worker (`src/worker/mossWorker.ts`) supervised by `src/moss/client.ts`; bundles all five native `moss-core` platform packages into the VSIX. Exact-pinned to the fixed crash-boundary versions — see Testing Notes | ### Reusable Packages (`packages/`) @@ -260,6 +261,7 @@ When adding examples or cookbook integrations, follow the existing patterns: - CI runs Python SDK tests across versions 3.10–3.14 in a GitHub Actions matrix using `pytest` directly - CI lints Python with `ruff` and type-checks with `mypy`; format locally with `black` + `isort` - JS tests use Vitest; CI runs on Node 20 +- `apps/moss-vscode` has its own hermetic regression suite (`apps/moss-vscode/test/`, `npm test`, gated by `.github/workflows/moss-vscode-ci.yml`): it forks the built worker, initializes through a loopback auth stub (`MOSS_AUTH_URL`), and asserts a torn on-disk session cache yields a catchable IPC error while the worker survives — plus a supervisor abort/restart negative control and a VSIX-integrity check (`scripts/verify-package.mjs`). It uses `modelId: "custom"` to stay offline (no model download); the native `loadFromDisk` deserialize boundary is model-independent. The app must stay at or above `@moss-dev/moss@1.4.1` / `@moss-dev/moss-core@0.20.1` (below that ships a native addon that can abort the process on a corrupt cache); `prepackage.mjs` and `verify-package.mjs` enforce that floor ## CI Workflows @@ -268,3 +270,4 @@ The `.github/workflows/ci.yml` pipeline runs on push to `main` and on PRs: - `python-sdk-test` — matrix over Python 3.10–3.14 - `javascript-lint` — eslint - Separate release workflows publish to PyPI / npm on tagged releases +- `moss-vscode-ci.yml` — builds, tests (worker-survival + supervisor negative control), packages, and verifies the `apps/moss-vscode` VSIX on macOS / Linux / Windows diff --git a/apps/moss-vscode/.vscodeignore b/apps/moss-vscode/.vscodeignore index 9c7c7e8a..8810d57a 100644 --- a/apps/moss-vscode/.vscodeignore +++ b/apps/moss-vscode/.vscodeignore @@ -2,10 +2,14 @@ .vscode-test/** src/** **/*.ts -!dist/** tsconfig.json esbuild.mjs scripts/** +test/** +promo/** +**/*.test.mjs +*.log +**/*.log .env .env.* .gitignore diff --git a/apps/moss-vscode/PUBLISHING.md b/apps/moss-vscode/PUBLISHING.md index a1e90713..9092a490 100644 --- a/apps/moss-vscode/PUBLISHING.md +++ b/apps/moss-vscode/PUBLISHING.md @@ -62,4 +62,4 @@ The extension runs Moss in a child Node process. Resolution order: 3. System Node (`node` on PATH) 4. VS Code embedded Node (`process.execPath` + `ELECTRON_RUN_AS_NODE`) -If indexing fails with a native crash, install Node 20+ and set `moss.nodePath`, or set `NODE_BINARY`. +If indexing fails with a native crash, install Node 20.4+ and set `moss.nodePath`, or set `NODE_BINARY`. diff --git a/apps/moss-vscode/package-lock.json b/apps/moss-vscode/package-lock.json index f54b1ce3..7ac79db2 100644 --- a/apps/moss-vscode/package-lock.json +++ b/apps/moss-vscode/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "BSD-2-Clause", "dependencies": { - "@moss-dev/moss": "^1.3.2", + "@moss-dev/moss": "1.4.1", "dotenv": "^17.2.3", "ignore": "^7.0.5" }, @@ -21,7 +21,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=20", + "node": ">=20.4", "vscode": "^1.85.0" } }, @@ -678,34 +678,37 @@ } }, "node_modules/@moss-dev/moss": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss/-/moss-1.3.2.tgz", - "integrity": "sha512-T/hX6aicJEC82F2egj+nZRxXsYjx59nnipk7G3MfanCUF3mnuPAuvJ833tgDasD0OB0BW8F/KuOcK1y9K2gM0g==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss/-/moss-1.4.1.tgz", + "integrity": "sha512-0eA6Y7BytghVf0QY+lW+b5rbY/vE+2DheEattVun0urSGN3EuvSwYxI+y5GBZLDFCjoaQSTqvTB2SC9vY7aPxw==", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@moss-dev/moss-core": "0.19.2" + "@moss-dev/moss-core": "0.20.1" + }, + "engines": { + "node": ">=20.4" } }, "node_modules/@moss-dev/moss-core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss-core/-/moss-core-0.19.2.tgz", - "integrity": "sha512-DWp+rWymKdZn3r+846o04OyfEXL2Z/9kPLc65zji255kO0u5BUHmdVxA/DqA3JyKmWZyJpf1034nJVn13dfX1A==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core/-/moss-core-0.20.1.tgz", + "integrity": "sha512-yk9lweZgKjT9QhJ5sb0Nb7upNsijpQ26jaOuT3mI6UXkysEat2/nYc4RRZnUJWeZw8+k2tX2COo0UZ3tzwHe+A==", "license": "Proprietary", "engines": { "node": ">=20" }, "optionalDependencies": { - "@moss-dev/moss-core-darwin-arm64": "0.19.2", - "@moss-dev/moss-core-darwin-x64": "0.19.2", - "@moss-dev/moss-core-linux-arm64-gnu": "0.19.2", - "@moss-dev/moss-core-linux-x64-gnu": "0.19.2", - "@moss-dev/moss-core-win32-x64-msvc": "0.19.2" + "@moss-dev/moss-core-darwin-arm64": "0.20.1", + "@moss-dev/moss-core-darwin-x64": "0.20.1", + "@moss-dev/moss-core-linux-arm64-gnu": "0.20.1", + "@moss-dev/moss-core-linux-x64-gnu": "0.20.1", + "@moss-dev/moss-core-win32-x64-msvc": "0.20.1" } }, "node_modules/@moss-dev/moss-core-darwin-arm64": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-darwin-arm64/-/moss-core-darwin-arm64-0.19.2.tgz", - "integrity": "sha512-8466vdNBoD12D7L47ueLbjSBzjEsAYQ1Owbu80NiQVg/clcMSZ68LVle1AOhHpZuPUvEaG5GCBpz1wSiKs5dtw==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-darwin-arm64/-/moss-core-darwin-arm64-0.20.1.tgz", + "integrity": "sha512-FlPmYuKiZr3jm+RNTdyz1eMZ1UqkLCnuS67E6AZ0xysTIc3uel9cJ1XO2426hxX29c5Cxcgz8PvGVlyy+NRetw==", "cpu": [ "arm64" ], @@ -719,9 +722,9 @@ } }, "node_modules/@moss-dev/moss-core-darwin-x64": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-darwin-x64/-/moss-core-darwin-x64-0.19.2.tgz", - "integrity": "sha512-VRkcrWBxXb+bf7XTlbpmLGyGevucDm3g0ISTO3dpYeXbloatwaF2wOWWNliXuO0kBfIuvDxNBHPp4otiCd7WcQ==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-darwin-x64/-/moss-core-darwin-x64-0.20.1.tgz", + "integrity": "sha512-/x4ThBm/+DTA9f+qI5vnvHz1GJce02JP5UZoEnUaS2mGRXOADWlEw75+irMKdaxTC9E9LaTnzXdoJAriQNswRQ==", "cpu": [ "x64" ], @@ -735,15 +738,12 @@ } }, "node_modules/@moss-dev/moss-core-linux-arm64-gnu": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-linux-arm64-gnu/-/moss-core-linux-arm64-gnu-0.19.2.tgz", - "integrity": "sha512-LTN9saHwiUcOGn/GlHQwUETKecE6rzpAW/pbW60YS7ok7aRowJ1T1vVk06MMef7JGhPLdx7gZvNKb+cNvoph1Q==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-linux-arm64-gnu/-/moss-core-linux-arm64-gnu-0.20.1.tgz", + "integrity": "sha512-nsGjFQffgeTKOxS6j3b8iw4hsKYlijiUV1B4gYD8hGsx27+ItIx1b7ZYW1iYI+udB1zP4SmEyOLhhJvDtfDE6w==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Proprietary", "optional": true, "os": [ @@ -754,15 +754,12 @@ } }, "node_modules/@moss-dev/moss-core-linux-x64-gnu": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-linux-x64-gnu/-/moss-core-linux-x64-gnu-0.19.2.tgz", - "integrity": "sha512-T9BveLSejFO6RSTuqF09Lt4L7ntMpK2SB1Jrm+mt3DFX1WIhD0sOhoyV4i8ykldZ/6Uv5VI1aKVhtCvBGQxRoA==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-linux-x64-gnu/-/moss-core-linux-x64-gnu-0.20.1.tgz", + "integrity": "sha512-qppzVdaeNVV1OyrVky6kxuZZoQ2KqTmKzwOECdlcRcaEdvkXb9LpzDEcFkVZrKJdp7TOdCdRMvahCiN2t4ic4A==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Proprietary", "optional": true, "os": [ @@ -773,9 +770,9 @@ } }, "node_modules/@moss-dev/moss-core-win32-x64-msvc": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-win32-x64-msvc/-/moss-core-win32-x64-msvc-0.19.2.tgz", - "integrity": "sha512-QIt5lDzWuHqN9cPd6lCF0QdIbBJmUYGBCNewssM0/kLlQC1UPHcf7/ZBpr5avNngR5kvq3JQetIFNHmZrDAAaw==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@moss-dev/moss-core-win32-x64-msvc/-/moss-core-win32-x64-msvc-0.20.1.tgz", + "integrity": "sha512-VLyKeokmC4yVzNFtKpnTXJca9Bk8RZMvShkggKyU2qndiYsTxaEVNJJvRqkRYnXFNJ8y1SJjk7/Vo/M4FTx0Bg==", "cpu": [ "x64" ], diff --git a/apps/moss-vscode/package.json b/apps/moss-vscode/package.json index 752d9808..f546f18c 100644 --- a/apps/moss-vscode/package.json +++ b/apps/moss-vscode/package.json @@ -18,7 +18,7 @@ "qna": "https://github.com/samanyugoyal2010/moss-vs-code/issues", "engines": { "vscode": "^1.85.0", - "node": ">=20" + "node": ">=20.4" }, "categories": [ "Other", @@ -146,7 +146,7 @@ "moss.nodePath": { "type": "string", "default": "", - "description": "Optional path to a Node.js 20+ binary for the Moss worker (defaults to system Node, then VS Code embedded Node)" + "description": "Optional path to a Node.js 20.4+ binary for the Moss worker (defaults to system Node, then VS Code embedded Node)" } } } @@ -156,12 +156,15 @@ "build": "node esbuild.mjs", "watch": "node esbuild.mjs --watch", "typecheck": "tsc --noEmit", + "test": "npm run build && node --test test/worker-survival.test.mjs test/supervisor-negative-control.test.mjs test/node-floor.test.mjs", + "test:worker": "npm run build && node --test test/worker-survival.test.mjs", + "test:supervisor": "npm run build && node --test test/supervisor-negative-control.test.mjs", "prepackage": "node scripts/prepackage.mjs", "package": "npm run prepackage && vsce package", "verify-package": "node scripts/verify-package.mjs" }, "dependencies": { - "@moss-dev/moss": "^1.3.2", + "@moss-dev/moss": "1.4.1", "dotenv": "^17.2.3", "ignore": "^7.0.5" }, diff --git a/apps/moss-vscode/scripts/prepackage.mjs b/apps/moss-vscode/scripts/prepackage.mjs index c06a66b8..46912ed7 100644 --- a/apps/moss-vscode/scripts/prepackage.mjs +++ b/apps/moss-vscode/scripts/prepackage.mjs @@ -16,6 +16,25 @@ if (!mossCoreVersion) { throw new Error("Could not resolve @moss-dev/moss-core version for packaging"); } +// Crash-boundary floor (MOS-166): the packaged native addon must be at or above +// the fixed @moss-dev/moss-core 0.20.1 (shipped in @moss-dev/moss 1.4.1). Fail +// packaging early if the lockfile regresses below it. +const FIXED_CORE = "0.20.1"; +const cmp = (a, b) => { + const pa = a.split("-")[0].split(".").map(Number); + const pb = b.split("-")[0].split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0); + } + return 0; +}; +if (cmp(mossCoreVersion, FIXED_CORE) < 0) { + throw new Error( + `@moss-dev/moss-core ${mossCoreVersion} is below the fixed crash-boundary floor ${FIXED_CORE}; ` + + `refusing to package a source-affected native addon (MOS-166).`, + ); +} + const platformPackages = [ "@moss-dev/moss-core-darwin-arm64", "@moss-dev/moss-core-darwin-x64", diff --git a/apps/moss-vscode/scripts/verify-package.mjs b/apps/moss-vscode/scripts/verify-package.mjs index b669c517..0de6c667 100644 --- a/apps/moss-vscode/scripts/verify-package.mjs +++ b/apps/moss-vscode/scripts/verify-package.mjs @@ -1,19 +1,27 @@ +// VSIX integrity gate (MOS-166). +// +// Presence checks alone are insufficient. This packages nothing itself — it +// verifies the already-built .vsix by extracting it and asserting: +// 1. the bundled wrapper/core/platform versions are exactly the fixed pinned +// set (@moss-dev/moss 1.4.1 -> @moss-dev/moss-core 0.20.1 for all five +// platform packages), and the app lockfile has not dropped below it; +// 2. no test source, fixtures, or hidden test-hook / debug exports leaked into +// the package (in the JS wrapper, the worker bundle, or the native addon); +// 3. the extracted worker, run against the VSIX's OWN bundled .node, returns a +// catchable IPC error on a torn cache and stays alive — i.e. the packaged +// artifact actually ships the fixed crash boundary. + import { execSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, mkdtempSync, rmSync } from "node:fs"; +import * as os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const extensionRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); -const required = [ - "dist/extension.js", - "dist/mossWorker.js", - "media/icon.svg", - "node_modules/@moss-dev/moss/package.json", - "node_modules/@moss-dev/moss-core/package.json", -]; - -const platformPackages = [ +const FIXED_WRAPPER = "1.4.1"; +const FIXED_CORE = "0.20.1"; +const PLATFORM_PACKAGES = [ "@moss-dev/moss-core-darwin-arm64", "@moss-dev/moss-core-darwin-x64", "@moss-dev/moss-core-linux-arm64-gnu", @@ -21,31 +29,209 @@ const platformPackages = [ "@moss-dev/moss-core-win32-x64-msvc", ]; -for (const rel of required) { - const full = path.join(extensionRoot, rel); - if (!existsSync(full)) { - throw new Error(`Missing required package file: ${rel}`); - } +function fail(msg) { + console.error(`✗ ${msg}`); + process.exit(1); } -for (const pkg of platformPackages) { - const full = path.join(extensionRoot, "node_modules", pkg); - if (!existsSync(full)) { - throw new Error(`Missing cross-platform native package: ${pkg}`); +function parseVersion(v) { + return v.split("-")[0].split(".").map((n) => parseInt(n, 10)); +} +// Returns true if `a` >= `b` (major.minor.patch). +function gte(a, b) { + const [a0, a1, a2] = parseVersion(a); + const [b0, b1, b2] = parseVersion(b); + if (a0 !== b0) return a0 > b0; + if (a1 !== b1) return a1 > b1; + return a2 >= b2; +} + +// --------------------------------------------------------------------------- +// 0. Lockfile floor: packaging must fail if the app lock drops below the fix. +// --------------------------------------------------------------------------- +const lock = JSON.parse(readFileSync(path.join(extensionRoot, "package-lock.json"), "utf8")); +const lockWrapper = lock.packages?.["node_modules/@moss-dev/moss"]?.version; +const lockCore = lock.packages?.["node_modules/@moss-dev/moss-core"]?.version; +if (!lockWrapper || !gte(lockWrapper, FIXED_WRAPPER)) { + fail(`lockfile @moss-dev/moss is ${lockWrapper}, below fixed floor ${FIXED_WRAPPER}`); +} +if (!lockCore || !gte(lockCore, FIXED_CORE)) { + fail(`lockfile @moss-dev/moss-core is ${lockCore}, below fixed floor ${FIXED_CORE}`); +} +for (const pkg of PLATFORM_PACKAGES) { + const v = lock.packages?.[`node_modules/${pkg}`]?.version; + if (v !== lockCore) { + fail(`lockfile platform package ${pkg} is ${v}, expected same core version ${lockCore}`); } } +console.log(`✓ lockfile pins @moss-dev/moss ${lockWrapper} -> moss-core ${lockCore} (all platforms)`); + +// --------------------------------------------------------------------------- +// 1. Locate + extract the VSIX. +// --------------------------------------------------------------------------- +const vsixFiles = readdirSync(extensionRoot).filter((n) => n.endsWith(".vsix")); +if (!vsixFiles.length) fail("no .vsix file found — run `npm run package` first"); +const vsix = path.join(extensionRoot, vsixFiles.sort().at(-1)); -const vsixFiles = readdirSync(extensionRoot).filter((name) => name.endsWith(".vsix")); -if (!vsixFiles.length) { - throw new Error("No .vsix file found — run npm run package first"); +const listing = execSync(`unzip -Z1 "${vsix}"`, { encoding: "utf8" }).split(/\r?\n/).filter(Boolean); + +// --------------------------------------------------------------------------- +// 2. Reject leaks: no test source, fixtures, promo, logs, or TS source. +// --------------------------------------------------------------------------- +const leakRules = [ + { re: /^extension\/test\//, what: "test source/fixtures" }, + { re: /\.test\.mjs$/, what: "test file" }, + { re: /^extension\/src\//, what: "TypeScript source" }, + { re: /\.tsx?$/, what: "TypeScript source" }, + // Source maps embed the original TypeScript via `sourcesContent`, so shipping + // them would leak the very source the rules above reject. + { re: /\.map$/, what: "source map (embeds TypeScript source)" }, + { re: /^extension\/promo\//, what: "promo project" }, + { re: /\.log$/, what: "log file" }, + { re: /^extension\/scripts\//, what: "build/verify scripts" }, +]; +const leaks = listing.filter((entry) => leakRules.some((r) => r.re.test(entry))); +if (leaks.length) { + for (const l of leaks.slice(0, 20)) console.error(` leaked: ${l}`); + fail(`${leaks.length} disallowed file(s) leaked into the VSIX`); } +console.log("✓ no test source, fixtures, promo, logs, source maps, or TS source in the VSIX"); -const latestVsix = vsixFiles.sort().at(-1); -const listing = execSync(`unzip -l "${path.join(extensionRoot, latestVsix)}"`, { - encoding: "utf8", -}); -if (!listing.includes("node_modules/@moss-dev/moss-core/")) { - throw new Error(`VSIX ${latestVsix} does not bundle @moss-dev/moss-core`); +// Required contents still present. +const requiredEntries = [ + "extension/dist/extension.js", + "extension/dist/mossWorker.js", + "extension/node_modules/@moss-dev/moss/package.json", + "extension/node_modules/@moss-dev/moss-core/package.json", +]; +for (const rel of requiredEntries) { + if (!listing.includes(rel)) fail(`VSIX missing required entry: ${rel}`); } -console.log(`Package verification passed (${latestVsix}).`); +// --------------------------------------------------------------------------- +// 3. Extract and assert bundled versions + hidden-hook absence. +// --------------------------------------------------------------------------- +const extractDir = mkdtempSync(path.join(os.tmpdir(), "moss-vsix-verify-")); +try { + execSync(`unzip -q "${vsix}" -d "${extractDir}"`, { stdio: "inherit" }); + const modBase = path.join(extractDir, "extension", "node_modules"); + + const bundledWrapper = JSON.parse( + readFileSync(path.join(modBase, "@moss-dev", "moss", "package.json"), "utf8"), + ).version; + if (bundledWrapper !== FIXED_WRAPPER) { + fail(`VSIX bundles @moss-dev/moss ${bundledWrapper}, expected exactly ${FIXED_WRAPPER}`); + } + const bundledCore = JSON.parse( + readFileSync(path.join(modBase, "@moss-dev", "moss-core", "package.json"), "utf8"), + ).version; + if (bundledCore !== FIXED_CORE) { + fail(`VSIX bundles @moss-dev/moss-core ${bundledCore}, expected exactly ${FIXED_CORE}`); + } + for (const pkg of PLATFORM_PACKAGES) { + const pkgJson = path.join(modBase, ...pkg.split("/"), "package.json"); + if (!existsSync(pkgJson)) fail(`VSIX missing bundled platform package: ${pkg}`); + const v = JSON.parse(readFileSync(pkgJson, "utf8")).version; + if (v !== FIXED_CORE) fail(`VSIX platform package ${pkg} is ${v}, expected ${FIXED_CORE}`); + } + console.log(`✓ VSIX bundles fixed set: moss ${bundledWrapper} -> core ${bundledCore} (5 platforms)`); + + // Hidden test-hook / debug export scan. The shipped SDK JS and worker bundle + // must not expose panic/test/debug hooks, and EVERY packaged platform .node + // (not just the runner-native one) must carry no such symbols — a hook baked + // into any of the five bundled binaries ships to that platform's users. + const HOOK_RE = /__test_panic_hook|__debugWaiter|__debug[A-Z]|__test[A-Z]/; + const scanJsFiles = [ + path.join(modBase, "@moss-dev", "moss", "dist", "index.esm.js"), + path.join(modBase, "@moss-dev", "moss-core", "index.js"), + path.join(extractDir, "extension", "dist", "mossWorker.js"), + path.join(extractDir, "extension", "dist", "extension.js"), + ]; + for (const f of scanJsFiles) { + // A missing expected target must fail, not silently skip — otherwise a + // layout change turns the hook assertion into a no-op. + if (!existsSync(f)) fail(`expected JS scan target missing from VSIX: ${path.relative(extractDir, f)}`); + if (HOOK_RE.test(readFileSync(f, "utf8"))) { + fail(`hidden test-hook/debug export found in ${path.relative(extractDir, f)}`); + } + } + const scannedNodes = []; + for (const pkg of PLATFORM_PACKAGES) { + const dir = path.join(modBase, ...pkg.split("/")); + const nodeFile = existsSync(dir) ? readdirSync(dir).find((f) => f.endsWith(".node")) : undefined; + if (!nodeFile) fail(`VSIX platform package ${pkg} is missing its .node binary`); + const buf = readFileSync(path.join(dir, nodeFile)); + if (HOOK_RE.test(buf.toString("latin1"))) { + fail(`hidden test-hook/debug symbol found in bundled native addon ${pkg}/${nodeFile}`); + } + scannedNodes.push(nodeFile); + } + console.log(`✓ no hidden test-hook/debug symbols (JS + all ${scannedNodes.length} platform .node binaries)`); + + // ------------------------------------------------------------------------- + // 4. Execute the EXTRACTED worker against the VSIX's own bundled .node. + // ------------------------------------------------------------------------- + await executePackagedWorker(extractDir); + console.log(`✓ extracted worker survives a torn cache against its bundled .node`); +} finally { + rmSync(extractDir, { recursive: true, force: true }); +} + +console.log(`Package verification passed (${path.basename(vsix)}).`); + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- +async function executePackagedWorker(extractDir) { + const { startAuthStub } = await import("../test/support/authStub.mjs"); + const { buildBaselineCacheWith, makeCorruptCache, makeTempRoot, safeRm } = await import( + "../test/support/fixtures.mjs" + ); + const { WorkerHarness } = await import("../test/support/workerHarness.mjs"); + const { SESSION_NAME, STUB_PROJECT_ID, STUB_PROJECT_KEY, applyHermeticEnv } = await import( + "../test/support/env.mjs" + ); + + // Disable parent-process telemetry and isolate its model cache BEFORE the + // bundled SDK is imported/constructed for baseline generation. + applyHermeticEnv(); + + const workerPath = path.join(extractDir, "extension", "dist", "mossWorker.js"); + const stub = await startAuthStub(); + const modelCache = makeTempRoot("vsix-modelcache"); + const fixturesRoot = makeTempRoot("vsix-baseline"); + // Generate the baseline cache using the VSIX's OWN bundled SDK, so the fixture + // and the worker exercise the identical packaged native addon. + const bundledSdk = path.join(extractDir, "extension", "node_modules", "@moss-dev", "moss"); + const baselineDir = await buildBaselineCacheWith(fixturesRoot, bundledSdk); + + const worker = new WorkerHarness( + { MOSS_AUTH_URL: stub.authUrl, MOSS_INDEX_URL: stub.indexUrl, MOSS_MODEL_CACHE_DIR: modelCache }, + workerPath, + ); + try { + const init = await worker.call("initialize", { + projectId: STUB_PROJECT_ID, + projectKey: STUB_PROJECT_KEY, + name: SESSION_NAME, + modelId: "custom", + }); + if (init.docCount !== 0) fail(`packaged worker init docCount=${init.docCount}, expected 0`); + + const tornRoot = makeTempRoot("vsix-torn"); + makeCorruptCache(baselineDir, tornRoot, "torn_vector_count"); + const res = await worker.send("loadFromDisk", { cachePath: tornRoot }); + if (res.ok !== false) fail("packaged worker did not reject a torn cache (possible abort)"); + if (worker.exit !== null) fail(`packaged worker exited after torn cache: ${JSON.stringify(worker.exit)}`); + if (!worker.connected) fail("packaged worker not connected after torn cache"); + + const docs = await worker.call("getDocs", { options: undefined }); + if (docs.docCount !== 0) fail("packaged worker installed partial state after failed load"); + } finally { + await worker.dispose(); + await stub.close(); + // Worker is gone; releasing any mmap so best-effort cleanup is safe. + safeRm(fixturesRoot); + safeRm(modelCache); + } +} diff --git a/apps/moss-vscode/src/moss/client.ts b/apps/moss-vscode/src/moss/client.ts index fa25ad02..ed808cf6 100644 --- a/apps/moss-vscode/src/moss/client.ts +++ b/apps/moss-vscode/src/moss/client.ts @@ -192,6 +192,68 @@ function isExecutable(filePath: string): boolean { } } +/** + * Minimum Node.js the packaged `@moss-dev/moss` (1.4.1) supports at runtime. + * The wrapper declares `engines.node: ">=20.4"`; the worker must run on a Node + * that meets that floor, so binary selection verifies the version rather than + * only that a binary exists. + */ +export const MIN_WORKER_NODE_VERSION = "20.4.0"; + +export function parseNodeVersion( + raw: string | undefined, +): { major: number; minor: number; patch: number } | undefined { + if (!raw) { + return undefined; + } + // Anchored to a clean stable `X.Y.Z` (optional leading `v`). A prerelease such + // as `20.4.0-rc.1` or any trailing tag is rejected, so a pre-release of 20.4.0 + // — which semantically precedes the stable 20.4.0 — does not satisfy the + // stable `>=20.4` floor. + const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(raw.trim()); + if (!match) { + return undefined; + } + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }; +} + +export function nodeMeetsWorkerFloor(raw: string | undefined): boolean { + const version = parseNodeVersion(raw); + if (!version) { + return false; + } + const [minMajor, minMinor, minPatch] = MIN_WORKER_NODE_VERSION.split(".").map(Number); + if (version.major !== minMajor) { + return version.major > minMajor; + } + if (version.minor !== minMinor) { + return version.minor > minMinor; + } + return version.patch >= minPatch; +} + +/** + * Resolve a candidate's runtime Node version. Runs the binary with + * `ELECTRON_RUN_AS_NODE=1` so that VS Code's embedded Electron reports the + * Node version it bundles (not the Electron version), matching how the worker + * is forked. + */ +function nodeBinaryVersion(candidate: string): string | undefined { + try { + const result = spawnSync(candidate, ["-e", "process.stdout.write(process.versions.node)"], { + encoding: "utf8", + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + timeout: 5000, + }); + if (result.status !== 0) { + return undefined; + } + return result.stdout?.trim() || undefined; + } catch { + return undefined; + } +} + function findNodeBinary(log: (message: string) => void = () => undefined): string { const fromSetting = vscode.workspace.getConfiguration("moss").get("nodePath")?.trim(); @@ -210,17 +272,31 @@ function findNodeBinary(log: (message: string) => void = () => undefined): strin process.execPath, ].filter(Boolean) as string[]; + let tooOld: { candidate: string; version: string } | undefined; for (const candidate of candidates) { - if (isExecutable(candidate)) { - if (candidate === process.execPath) { - log("Moss worker using VS Code embedded Node (set moss.nodePath for a standalone Node 20+ binary if needed)"); + if (!isExecutable(candidate)) { + continue; + } + const version = nodeBinaryVersion(candidate); + if (!nodeMeetsWorkerFloor(version)) { + if (version) { + tooOld = { candidate, version }; } - return candidate; + continue; + } + if (candidate === process.execPath) { + log( + `Moss worker using VS Code embedded Node ${version} (set moss.nodePath for a standalone Node ${MIN_WORKER_NODE_VERSION}+ binary if needed)`, + ); } + return candidate; } + const detail = tooOld + ? ` The closest match, ${tooOld.candidate}, is Node ${tooOld.version}, below the required ${MIN_WORKER_NODE_VERSION} (@moss-dev/moss 1.4.1).` + : ""; throw new Error( - "Could not find a Node.js binary for the Moss worker. Install Node 20+ or set moss.nodePath.", + `Could not find a Node.js ${MIN_WORKER_NODE_VERSION}+ binary for the Moss worker.${detail} Install Node ${MIN_WORKER_NODE_VERSION}+ or set moss.nodePath.`, ); } diff --git a/apps/moss-vscode/test/node-floor.test.mjs b/apps/moss-vscode/test/node-floor.test.mjs new file mode 100644 index 00000000..416d1378 --- /dev/null +++ b/apps/moss-vscode/test/node-floor.test.mjs @@ -0,0 +1,41 @@ +// Node-floor alignment (MOS-166, finding #3 / captain Option A). +// +// The packaged @moss-dev/moss 1.4.1 declares engines.node ">=20.4", so the +// extension must not run its worker on an older Node. These assertions exercise +// the REAL version-gate helpers exported from src/moss/client.ts (bundled with a +// vscode stub, no production change) that findNodeBinary uses to reject a +// too-old candidate binary. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { loadSupervisorModule } from "./support/loadSupervisor.mjs"; + +test("worker Node floor is 20.4 and enforced correctly", async () => { + const { MIN_WORKER_NODE_VERSION, parseNodeVersion, nodeMeetsWorkerFloor } = + await loadSupervisorModule(); + + assert.equal(MIN_WORKER_NODE_VERSION, "20.4.0", "floor matches the SDK's engines.node >=20.4"); + + assert.deepEqual(parseNodeVersion("v20.4.0"), { major: 20, minor: 4, patch: 0 }); + assert.deepEqual(parseNodeVersion("20.10.1"), { major: 20, minor: 10, patch: 1 }); + assert.equal(parseNodeVersion(undefined), undefined); + assert.equal(parseNodeVersion("not-a-version"), undefined); + // Prereleases / trailing tags are not clean stable versions. + assert.equal(parseNodeVersion("20.4.0-rc.1"), undefined); + assert.equal(parseNodeVersion("20.5.0-nightly20240101abcdef"), undefined); + assert.equal(parseNodeVersion("20.4.0+build.5"), undefined); + + // At or above the floor. + for (const ok of ["20.4.0", "v20.4.0", "20.4.9", "20.10.0", "21.0.0", "22.11.0"]) { + assert.equal(nodeMeetsWorkerFloor(ok), true, `${ok} should meet the floor`); + } + // Below the floor or unparseable. + for (const bad of ["20.3.9", "20.0.0", "18.19.0", "16.20.2", undefined, "", "garbage"]) { + assert.equal(nodeMeetsWorkerFloor(bad), false, `${bad} should not meet the floor`); + } + // Prerelease of 20.4.0 precedes the stable release, so it must NOT satisfy the + // stable >=20.4 floor — even a prerelease of a higher line is rejected. + for (const pre of ["20.4.0-rc.1", "20.4.0-nightly", "20.5.0-rc.0", "21.0.0-pre"]) { + assert.equal(nodeMeetsWorkerFloor(pre), false, `${pre} (prerelease) must not meet the floor`); + } +}); diff --git a/apps/moss-vscode/test/supervisor-negative-control.test.mjs b/apps/moss-vscode/test/supervisor-negative-control.test.mjs new file mode 100644 index 00000000..77398a9a --- /dev/null +++ b/apps/moss-vscode/test/supervisor-negative-control.test.mjs @@ -0,0 +1,128 @@ +// Layer 3 — Supervisor negative control (MOS-166). +// +// Drives the REAL extension-host supervisor (src/moss/client.ts +// `MossSessionManager`, bundled with a vscode stub — no production change) and +// deliberately ABORTS its worker with a signal while a call is in flight. This +// is the counterpart to the worker-survival layer: it proves the host correctly +// handles the *uncatchable* failure mode (signal/abort), as opposed to the +// catchable-throw path. +// +// It asserts the supervisor: +// * records the worker exit code/signal, +// * rejects all pending calls (surfacing a crash error), +// * clears session state, and +// * starts a fresh replacement worker. +// +// Hermetic: the auth stub is held in "hang" mode so the worker blocks at the +// native credential-validation step (before any embedding model is touched), +// giving a deterministic in-flight call to abort and a model-free restart proof. + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { startAuthStub } from "./support/authStub.mjs"; +import { loadSupervisorModule } from "./support/loadSupervisor.mjs"; +import { makeTempRoot } from "./support/fixtures.mjs"; +import { STUB_PROJECT_ID, STUB_PROJECT_KEY } from "./support/env.mjs"; + +const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function waitFor(predicate, { timeout = 10000, interval = 20 } = {}) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + let ok = false; + try { + ok = predicate(); + } catch { + ok = false; + } + if (ok) return resolve(); + if (Date.now() - start > timeout) return reject(new Error("waitFor timed out")); + setTimeout(tick, interval); + }; + tick(); + }); +} + +let stub; +let MossSessionManager; +let modelCacheDir; + +before(async () => { + ({ MossSessionManager } = await loadSupervisorModule()); + stub = await startAuthStub({ hang: true }); + modelCacheDir = makeTempRoot("supervisor-modelcache"); + // ensureWorker() forks with {...process.env}; route the worker to the loopback + // stub and an isolated model cache. + process.env.MOSS_AUTH_URL = stub.authUrl; + process.env.MOSS_INDEX_URL = stub.indexUrl; + process.env.MOSS_MODEL_CACHE_DIR = modelCacheDir; + process.env.MOSS_DISABLE_TELEMETRY = "1"; +}); + +after(async () => { + await stub?.close(); +}); + +test("aborting the worker mid-call: records signal, rejects pending, clears state, restarts", async () => { + const logs = []; + const manager = new MossSessionManager(APP_ROOT, (m) => logs.push(m)); + const creds = { projectId: STUB_PROJECT_ID, projectKey: STUB_PROJECT_KEY }; + + // 1) Start an initialize() that blocks at native auth (stub is hanging). + const requestsBefore = stub.requests; + const initPromise = manager.initialize(creds); + initPromise.catch(() => {}); // handled via assert.rejects below + + // The worker is forked synchronously inside ensureWorker(). + const worker1 = manager.worker; + assert.ok(worker1 && typeof worker1.pid === "number", "supervisor forked a worker"); + const pid1 = worker1.pid; + + // Wait until the worker actually reached the native credential validation, + // i.e. a call is genuinely in flight. + await waitFor(() => stub.requests > requestsBefore); + + // 2) Deliberately abort the worker with a signal (models a native abort). + worker1.kill("SIGKILL"); + + // 3) The pending initialize() call must be rejected as a crash, not hang. + await assert.rejects(initPromise, /crash/i, "pending call rejected as a crash"); + + // ...and the host must have recorded the exit code/signal. This is the + // signal/abort path — the worker process actually EXITED, unlike the + // catchable-throw path (worker-survival.test.mjs) where it stays alive. + const exitLog = logs.find((l) => l.includes("Moss worker exited")); + assert.ok(exitLog, "supervisor logged the worker exit"); + // Node reports a kill("SIGKILL") as signal=SIGKILL / code=null on Windows too. + assert.match(exitLog, /signal=SIGKILL/, "recorded the abort signal (distinguishes signal/abort)"); + assert.match(exitLog, /code=null/, "no ordinary exit code on a signal death"); + + // 4) State cleared. + assert.equal(manager.isReady, false, "not ready after crash"); + assert.throws(() => manager.getSession(), /not initialized/, "session cleared after crash"); + assert.equal(manager.worker, undefined, "worker handle cleared after crash"); + + // 5) A fresh initialize() starts a REPLACEMENT worker (new process, connected, + // reaching the native layer). Kept in hang mode -> model-free restart proof. + const requestsBeforeRestart = stub.requests; + const restartPromise = manager.initialize(creds); + restartPromise.catch(() => {}); // torn down in cleanup + + await waitFor(() => manager.worker && manager.worker.pid !== pid1 && manager.worker.connected); + const pid2 = manager.worker.pid; + assert.notEqual(pid2, pid1, "replacement worker is a new process"); + await waitFor(() => stub.requests > requestsBeforeRestart); + assert.ok(manager.worker.connected, "replacement worker is IPC-connected"); + + // Cleanup: dispose rejects the still-pending restart call. + manager.dispose(); + await restartPromise.catch(() => {}); + + // Model-free: the isolated cache stayed empty (worker never passed auth). + const entries = fs.existsSync(modelCacheDir) ? fs.readdirSync(modelCacheDir) : []; + assert.deepEqual(entries, [], `model cache must stay empty, found: ${entries.join(", ")}`); +}); diff --git a/apps/moss-vscode/test/support/authStub.mjs b/apps/moss-vscode/test/support/authStub.mjs new file mode 100644 index 00000000..b756d3b7 --- /dev/null +++ b/apps/moss-vscode/test/support/authStub.mjs @@ -0,0 +1,68 @@ +// Loopback auth stub — a "local auth stub" standing in for the Moss identity +// service. The native addon and the SDK both honour MOSS_AUTH_URL, so pointing +// it at this loopback server lets the shipped worker's static-key session path +// (`new MossClient(projectId, projectKey)` -> `SessionIndex.create`) complete +// credential validation without any real credentials or off-box network. +// +// No customer data, no real tokens: every response is a fixed synthetic token. + +import * as http from "node:http"; + +/** + * Start the loopback auth stub. + * + * @param {object} [opts] + * @param {boolean} [opts.hang] When true, requests are accepted but never + * answered — used by the supervisor negative control to hold a worker call + * pending while the worker is aborted. + * @returns {Promise<{ authUrl: string, indexUrl: string, requests: number, + * setHang: (v: boolean) => void, close: () => Promise }>} + */ +export async function startAuthStub(opts = {}) { + let hang = !!opts.hang; + let requests = 0; + /** @type {Set} */ + const held = new Set(); + + const server = http.createServer((req, res) => { + requests += 1; + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + if (hang) { + held.add(res); + res.on("close", () => held.delete(res)); + return; // deliberately never respond + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ token: "local-stub-jwt", expiresIn: 3600 })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + const origin = `http://127.0.0.1:${port}`; + + return { + authUrl: `${origin}/identity/auth/token`, + indexUrl: `${origin}/index`, + get requests() { + return requests; + }, + setHang(v) { + hang = !!v; + if (!hang) { + for (const res of held) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ token: "local-stub-jwt", expiresIn: 3600 })); + } + held.clear(); + } + }, + async close() { + for (const res of held) res.destroy(); + held.clear(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} diff --git a/apps/moss-vscode/test/support/env.mjs b/apps/moss-vscode/test/support/env.mjs new file mode 100644 index 00000000..d378f9b8 --- /dev/null +++ b/apps/moss-vscode/test/support/env.mjs @@ -0,0 +1,51 @@ +// Shared, hermetic environment setup for the Moss VS Code regression suite. +// +// The regression gate is deliberately offline and deterministic: +// * MOSS_DISABLE_TELEMETRY=1 — no telemetry network calls. +// * MOSS_MODEL_CACHE_DIR= — an isolated, empty model cache so a stray +// model load can never silently reach the public model host; the gate uses +// `modelId: "custom"` which loads no embedding model at all. +// * MOSS_AUTH_URL / MOSS_INDEX_URL are pointed at a loopback auth stub by the +// worker-survival test (see support/authStub.mjs) so credential validation +// in the native addon never leaves the machine. +// +// The crash boundary under test — `SessionIndex.loadFromDisk` deserializing a +// torn on-disk cache — lives in the native addon's format/validation code and is +// independent of the embedding model. Exercising it with `custom` embeddings +// therefore drives the identical native deserialize path the shipped +// `moss-minilm` sessions use, while keeping CI fully hermetic. + +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs"; + +/** Session name shared by the fixture generator and the worker initializer. */ +export const SESSION_NAME = "vscode-regression-session"; + +/** Embedding dimension for the seeded custom-embedding fixtures. */ +export const EMBED_DIM = 16; + +/** Deterministic project credentials — never real, never network-bound. */ +export const STUB_PROJECT_ID = "local-regression-project"; +export const STUB_PROJECT_KEY = "local-regression-key"; + +/** + * Apply the hermetic environment to `process.env` (or a copy). Returns the + * model-cache directory so callers can assert it stayed empty (no download). + */ +export function applyHermeticEnv(env = process.env) { + env.MOSS_DISABLE_TELEMETRY = "1"; + const modelCache = fs.mkdtempSync(path.join(os.tmpdir(), "moss-vscode-modelcache-")); + env.MOSS_MODEL_CACHE_DIR = modelCache; + return modelCache; +} + +/** Build the env object passed to a forked worker (mirrors client.ts). */ +export function workerEnv(extra = {}) { + return { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + MOSS_DISABLE_TELEMETRY: "1", + ...extra, + }; +} diff --git a/apps/moss-vscode/test/support/fixtures.mjs b/apps/moss-vscode/test/support/fixtures.mjs new file mode 100644 index 00000000..c2758f6d --- /dev/null +++ b/apps/moss-vscode/test/support/fixtures.mjs @@ -0,0 +1,173 @@ +// Deterministic, seeded, privacy-safe session-cache fixtures. +// +// A valid baseline cache is produced once by the REAL installed native addon +// (`saveToDisk`) using synthetic, seeded custom embeddings — no customer data, +// no network, no embedding model. Each corruption is a deterministic mutation +// of a fresh copy of that baseline, reproducing the torn/corrupt on-disk shapes +// that must yield a catchable JS error rather than aborting the process. +// +// Fixtures are generated into a temp directory at test time and never committed, +// so they cannot leak into the packaged VSIX. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import { SESSION_NAME, EMBED_DIM, STUB_PROJECT_ID } from "./env.mjs"; + +/** Deterministic pseudo-random embedding for a given seed (LCG, no Math.random). */ +function seededEmbedding(seed) { + const v = new Array(EMBED_DIM); + let x = (seed * 2654435761) >>> 0; + for (let i = 0; i < EMBED_DIM; i++) { + x = (1103515245 * x + 12345) >>> 0; + v[i] = ((x % 2000) / 1000) - 1; // in [-1, 1) + } + return v; +} + +/** The seeded, privacy-safe document corpus (synthetic text only). */ +export const SEED_DOCS = [ + { id: "doc-1", text: "alpha regression fixture", embedding: seededEmbedding(1) }, + { id: "doc-2", text: "bravo regression fixture", embedding: seededEmbedding(2) }, + { id: "doc-3", text: "charlie regression fixture", embedding: seededEmbedding(3) }, +]; + +/** In-process custom authenticator — resolves a synthetic token, no network. */ +function localAuthenticator() { + return { + async getAuthToken() { + return { token: "local-fixture-token", expiresIn: 3600 }; + }, + async getAuthHeader() { + return "Bearer local-fixture-token"; + }, + }; +} + +/** + * Build a valid baseline cache at `//` using the real + * addon. Uses the custom-authenticator bridge + `modelId: "custom"` so the whole + * operation is offline and model-free. Returns the session directory path. + */ +export async function buildBaselineCache(cacheRoot) { + const { MossClient } = await import("@moss-dev/moss"); + return buildBaselineWith(cacheRoot, MossClient); +} + +/** + * Same as buildBaselineCache but loads the SDK from a specific install dir + * (e.g. the SDK bundled inside an extracted VSIX), so the fixture and the worker + * exercise the identical packaged native addon. + */ +export async function buildBaselineCacheWith(cacheRoot, sdkDir) { + const pkg = JSON.parse(fs.readFileSync(path.join(sdkDir, "package.json"), "utf8")); + const entry = path.join(sdkDir, pkg.main ?? "dist/index.esm.js"); + const { MossClient } = await import(pathToFileURL(entry).href); + return buildBaselineWith(cacheRoot, MossClient); +} + +async function buildBaselineWith(cacheRoot, MossClient) { + fs.rmSync(cacheRoot, { recursive: true, force: true }); + fs.mkdirSync(cacheRoot, { recursive: true }); + const client = new MossClient(STUB_PROJECT_ID, localAuthenticator()); + const session = await client.session(SESSION_NAME, "custom"); + await session.addDocs(SEED_DOCS); + await session.saveToDisk(cacheRoot); + await session.close?.(); + await client.close?.(); + + const dir = path.join(cacheRoot, SESSION_NAME); + const files = fs.readdirSync(dir).sort(); + const expected = ["docs.json", "index.mossvec", "session.json"]; + for (const f of expected) { + if (!files.includes(f)) { + throw new Error(`baseline cache missing ${f}; got ${files.join(", ")}`); + } + } + return dir; +} + +/** + * The corruption catalog. Each entry mutates a session directory in place to + * produce one torn/corrupt shape. All are deterministic given the baseline. + */ +export const CORRUPTIONS = { + // .mossvec header claims N vectors but only 1 vector's bytes are present. + // The 304-byte header is fixed-size; truncate to header + one vector. + torn_vector_count: (dir) => { + const p = path.join(dir, "index.mossvec"); + const buf = fs.readFileSync(p); + const headerLen = buf.length - SEED_DOCS.length * EMBED_DIM * 4; + fs.writeFileSync(p, buf.subarray(0, headerLen + 1 * EMBED_DIM * 4)); + }, + // Vector data truncated mid-entry (last vector cut short). + truncated_mid_vector: (dir) => { + const p = path.join(dir, "index.mossvec"); + const buf = fs.readFileSync(p); + fs.writeFileSync(p, buf.subarray(0, buf.length - 10)); + }, + // Empty sidecar (interrupted write before any bytes). + empty_vector_file: (dir) => { + fs.writeFileSync(path.join(dir, "index.mossvec"), Buffer.alloc(0)); + }, + // Corrupt magic bytes. + bad_magic: (dir) => { + const p = path.join(dir, "index.mossvec"); + const buf = fs.readFileSync(p); + buf.write("XXXX", 0, "utf8"); + fs.writeFileSync(p, buf); + }, + // Required sidecar missing entirely. + missing_vector_file: (dir) => { + fs.rmSync(path.join(dir, "index.mossvec")); + }, + // Metadata/vector count mismatch (session.json claims fewer docIds). + docids_count_mismatch: (dir) => { + const p = path.join(dir, "session.json"); + const meta = JSON.parse(fs.readFileSync(p, "utf8")); + meta.docIds = meta.docIds.slice(0, meta.docIds.length - 1); + fs.writeFileSync(p, JSON.stringify(meta)); + }, + // Malformed JSON in docs.json. + invalid_docs_json: (dir) => { + fs.writeFileSync(path.join(dir, "docs.json"), "{ this is not valid json"); + }, +}; + +/** + * Materialize one corrupt cache. Copies the baseline session dir into a fresh + * `//` and applies the named corruption. Returns + * `destRoot` (the cachePath passed to loadFromDisk). + */ +export function makeCorruptCache(baselineDir, destRoot, corruptionName) { + const mutate = CORRUPTIONS[corruptionName]; + if (!mutate) throw new Error(`unknown corruption: ${corruptionName}`); + const destDir = path.join(destRoot, SESSION_NAME); + fs.rmSync(destRoot, { recursive: true, force: true }); + fs.mkdirSync(destDir, { recursive: true }); + for (const f of fs.readdirSync(baselineDir)) { + fs.copyFileSync(path.join(baselineDir, f), path.join(destDir, f)); + } + mutate(destDir); + return destRoot; +} + +/** Create an isolated temp root for fixtures under the OS temp dir. */ +export function makeTempRoot(label) { + return fs.mkdtempSync(path.join(os.tmpdir(), `moss-vscode-${label}-`)); +} + +/** + * Best-effort recursive remove. A live worker memory-maps `index.mossvec`, and + * Windows refuses to unlink a mapped file (EPERM) until the worker exits — the + * lingering temp dir is harmless and the OS reclaims it, so cleanup failures are + * ignored. Never used for assertions. + */ +export function safeRm(target) { + try { + fs.rmSync(target, { recursive: true, force: true }); + } catch { + // ignore — see doc comment (Windows mmap/EPERM); temp dir is ephemeral. + } +} diff --git a/apps/moss-vscode/test/support/loadSupervisor.mjs b/apps/moss-vscode/test/support/loadSupervisor.mjs new file mode 100644 index 00000000..b1061594 --- /dev/null +++ b/apps/moss-vscode/test/support/loadSupervisor.mjs @@ -0,0 +1,42 @@ +// Bundles the REAL supervisor (src/moss/client.ts) for use in a Node test +// process by aliasing the `vscode` host to test/support/vscodeStub.mjs. This +// lets the negative-control test assert the shipped MossSessionManager's +// crash-handling behavior directly, with no production changes — the same +// esbuild toolchain the extension already builds with. + +import * as esbuild from "esbuild"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const SUPPORT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SUPPORT_DIR, "..", ".."); + +let cached; + +/** Bundle + import client.ts once; returns its module exports (MossSessionManager, ...). */ +export async function loadSupervisorModule() { + if (cached) return cached; + const outfile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "moss-vscode-supervisor-")), "client.mjs"); + await esbuild.build({ + entryPoints: [path.join(APP_ROOT, "src", "moss", "client.ts")], + absWorkingDir: APP_ROOT, + bundle: true, + outfile, + format: "esm", + platform: "node", + target: "node20", + // Keep the native SDK external (loaded by the forked worker, not here). + external: ["@moss-dev/moss", "@moss-dev/moss-core"], + alias: { vscode: path.join(SUPPORT_DIR, "vscodeStub.mjs") }, + // Provide a real require() so bundled CJS deps (e.g. dotenv) can load node + // builtins under ESM output. + banner: { + js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);", + }, + logLevel: "silent", + }); + cached = await import(pathToFileURL(outfile).href); + return cached; +} diff --git a/apps/moss-vscode/test/support/vscodeStub.mjs b/apps/moss-vscode/test/support/vscodeStub.mjs new file mode 100644 index 00000000..0baa17e0 --- /dev/null +++ b/apps/moss-vscode/test/support/vscodeStub.mjs @@ -0,0 +1,37 @@ +// Minimal `vscode` module stub so the REAL extension-host supervisor +// (src/moss/client.ts `MossSessionManager`) can be exercised in a plain Node +// test process. Only the surface the supervisor path touches is implemented: +// * workspace.getConfiguration("moss").get(...) — used by findNodeBinary +// (returns undefined so it falls through to process.execPath) and by +// workspaceSessionName; +// * workspace.workspaceFolders / workspace.name — used by +// workspaceSessionName. +// No production code is changed; this only substitutes the editor host at +// bundle time. + +const configuration = { + get(_key, defaultValue) { + return defaultValue; + }, +}; + +export const workspace = { + workspaceFolders: undefined, + name: undefined, + getConfiguration() { + return configuration; + }, + update() { + return Promise.resolve(); + }, +}; + +export const window = { + showInputBox() { + return Promise.resolve(undefined); + }, +}; + +export const ConfigurationTarget = { Global: 1, Workspace: 2, WorkspaceFolder: 3 }; + +export default { workspace, window, ConfigurationTarget }; diff --git a/apps/moss-vscode/test/support/workerHarness.mjs b/apps/moss-vscode/test/support/workerHarness.mjs new file mode 100644 index 00000000..01d13997 --- /dev/null +++ b/apps/moss-vscode/test/support/workerHarness.mjs @@ -0,0 +1,107 @@ +// Forks the built worker (dist/mossWorker.js) exactly as the extension host does +// in src/moss/client.ts (IPC stdio layout, ELECTRON_RUN_AS_NODE, the same +// {id, method, args} -> {id, ok, result|error} request/response protocol), so +// the regression test drives the real shipped worker over the real IPC boundary. + +import { fork } from "node:child_process"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { workerEnv } from "./env.mjs"; + +const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +export const WORKER_PATH = path.join(APP_ROOT, "dist", "mossWorker.js"); + +/** Default per-request timeout (ms). A native deadlock must not hang the run. */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 30000; + +export class WorkerHarness { + constructor(envExtra = {}, workerPath = WORKER_PATH, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) { + this._nextId = 1; + this._pending = new Map(); + this.requestTimeoutMs = requestTimeoutMs; + this.exit = null; // { code, signal } once the worker exits + this._worker = fork(workerPath, [], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + execPath: process.execPath, + env: workerEnv(envExtra), + }); + this.stderr = ""; + this._worker.stderr?.on("data", (c) => (this.stderr += c.toString())); + + this._worker.on("message", (msg) => { + const pending = this._pending.get(msg.id); + if (!pending) return; + this._pending.delete(msg.id); + clearTimeout(pending.timer); + pending.resolve(msg); // {ok:false} is a value, not a throw, for assertions + }); + this._worker.on("exit", (code, signal) => { + this.exit = { code, signal }; + for (const { reject, timer } of this._pending.values()) { + clearTimeout(timer); + reject(new Error(`worker exited (code=${code}, signal=${signal})`)); + } + this._pending.clear(); + }); + } + + get connected() { + return !!this._worker.connected && this.exit === null; + } + + get pid() { + return this._worker.pid; + } + + /** Send a request; resolves with the raw response envelope ({ok:true|false}). */ + send(method, args) { + const id = this._nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this._pending.has(id)) return; + this._pending.delete(id); + // Terminate the stuck worker so a native deadlock can't wedge the run. + try { + this._worker.kill("SIGKILL"); + } catch { + // already gone + } + reject(new Error(`worker request '${method}' timed out after ${this.requestTimeoutMs}ms`)); + }, this.requestTimeoutMs); + if (typeof timer.unref === "function") timer.unref(); + this._pending.set(id, { resolve, reject, timer }); + this._worker.send({ id, method, args }, (err) => { + if (err) { + const pending = this._pending.get(id); + if (pending) clearTimeout(pending.timer); + this._pending.delete(id); + reject(err); + } + }); + }); + } + + /** Send a request and require it to succeed, returning `result`. */ + async call(method, args) { + const res = await this.send(method, args); + if (!res.ok) throw new Error(`worker call ${method} failed: ${res.error}`); + return res.result; + } + + kill(signal = "SIGKILL") { + this._worker.kill(signal); + } + + /** Wait for the worker to exit; resolves with { code, signal }. */ + waitForExit() { + if (this.exit) return Promise.resolve(this.exit); + return new Promise((resolve) => this._worker.once("exit", (code, signal) => resolve({ code, signal }))); + } + + async dispose() { + if (this.exit === null) { + this._worker.kill(); + await this.waitForExit().catch(() => {}); + } + } +} diff --git a/apps/moss-vscode/test/worker-survival.test.mjs b/apps/moss-vscode/test/worker-survival.test.mjs new file mode 100644 index 00000000..987b4cbc --- /dev/null +++ b/apps/moss-vscode/test/worker-survival.test.mjs @@ -0,0 +1,109 @@ +// Layer 1 — Worker-survival E2E (MOS-166). +// +// Boots the REAL built worker (dist/mossWorker.js) exactly as the extension host +// does, initializes a session through a loopback auth stub (no real credentials +// or off-box network), then feeds a battery of deterministic torn/corrupt +// on-disk caches to `loadFromDisk`. For each one it asserts: +// * the worker returns a catchable IPC error ({ ok: false }), not an abort; +// * the worker process stays alive and IPC-connected; +// * no partial state is installed (docCount unchanged); and +// * the worker still answers a subsequent safe request. +// +// This runs against the real installed addon for the runner's platform. The CI +// matrix executes three of the five shipped native targets — macOS arm64, Linux +// x64, Windows x64 (the hosted runners GitHub offers); Darwin x64 and Linux +// arm64 are bundled and statically version/hook-checked by verify-package but +// not executed (no hosted runners). The native `loadFromDisk` deserialize path +// is model-independent, so the hermetic `custom` model faithfully drives the +// same boundary the shipped `moss-minilm` sessions hit — see support/env.mjs. + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { startAuthStub } from "./support/authStub.mjs"; +import { buildBaselineCache, makeCorruptCache, makeTempRoot, safeRm, CORRUPTIONS, SEED_DOCS } from "./support/fixtures.mjs"; +import { WorkerHarness } from "./support/workerHarness.mjs"; +import { SESSION_NAME, STUB_PROJECT_ID, STUB_PROJECT_KEY, applyHermeticEnv } from "./support/env.mjs"; + +let stub; +let worker; +let modelCacheDir; +let baselineDir; +let fixturesRoot; + +before(async () => { + // Disable parent-process telemetry and isolate its model cache BEFORE the + // real-addon baseline is built in this process. + applyHermeticEnv(); + stub = await startAuthStub(); + fixturesRoot = makeTempRoot("survival-baseline"); + baselineDir = await buildBaselineCache(fixturesRoot); + + modelCacheDir = makeTempRoot("survival-modelcache"); + worker = new WorkerHarness({ + MOSS_AUTH_URL: stub.authUrl, + MOSS_INDEX_URL: stub.indexUrl, + MOSS_MODEL_CACHE_DIR: modelCacheDir, + }); + + // Initialize exactly as the host does (see client.ts initialize()), but with a + // hermetic, model-free `custom` session. + const init = await worker.call("initialize", { + projectId: STUB_PROJECT_ID, + projectKey: STUB_PROJECT_KEY, + name: SESSION_NAME, + modelId: "custom", + }); + assert.equal(init.docCount, 0, "fresh session starts empty"); + assert.ok(worker.connected, "worker connected after initialize"); +}); + +after(async () => { + await worker?.dispose(); + await stub?.close(); +}); + +for (const corruptionName of Object.keys(CORRUPTIONS)) { + test(`corrupt cache "${corruptionName}" -> catchable IPC error, worker survives`, async () => { + const root = makeTempRoot(`survival-${corruptionName}`); + makeCorruptCache(baselineDir, root, corruptionName); + + const res = await worker.send("loadFromDisk", { cachePath: root }); + + assert.equal(res.ok, false, `loadFromDisk on ${corruptionName} must reject, not abort`); + assert.equal(typeof res.error, "string"); + assert.ok(res.error.length > 0, "error message present"); + + // Process-survival: still connected, no exit recorded. + assert.equal(worker.exit, null, "worker must not have exited"); + assert.ok(worker.connected, "worker must remain IPC-connected"); + + // No partial state installed by the failed load. + const docs = await worker.call("getDocs", { options: undefined }); + assert.equal(docs.docCount, 0, "no partial documents installed after failed load"); + + safeRm(root); + }); +} + +test("worker answers a subsequent safe request after the fault battery", async () => { + // A safe mutation succeeds (custom embeddings) — proves full liveness. + const added = await worker.call("addDocs", { docs: SEED_DOCS }); + assert.equal(added.docCount, SEED_DOCS.length, "safe addDocs succeeds after faults"); + + // A clean round-trip load of a valid baseline cache still works. + const cleanRoot = makeTempRoot("survival-clean"); + fs.cpSync(baselineDir, `${cleanRoot}/${SESSION_NAME}`, { recursive: true }); + const loaded = await worker.call("loadFromDisk", { cachePath: cleanRoot }); + assert.equal(loaded.loaded, SEED_DOCS.length, "valid cache loads cleanly"); + assert.ok(worker.connected, "worker still connected at end"); + // Best-effort: the worker holds an mmap on the loaded cache (Windows EPERM). + safeRm(cleanRoot); +}); + +test("no embedding model was downloaded (hermetic, offline)", () => { + // The custom model loads nothing; the isolated model cache must be empty, + // proving the gate did not reach the public model host. + const entries = fs.existsSync(modelCacheDir) ? fs.readdirSync(modelCacheDir) : []; + assert.deepEqual(entries, [], `model cache must stay empty, found: ${entries.join(", ")}`); +});