From eee8c5fa974f9531fc8fae48b79b497295cf6bdc Mon Sep 17 00:00:00 2001 From: 1052326311 <65798732+1052326311@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:37:02 +0800 Subject: [PATCH] fix(session): compare entrypoint file identity Fixes #8844 Supersedes #8849 Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- src/serverIdentity.ts | 37 ++++++++++++++++----- tests/boundary/mcp/serverIdentity.test.ts | 39 ++++++++++++++++++++++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/serverIdentity.ts b/src/serverIdentity.ts index 9ee037dcbd..a6d543843f 100644 --- a/src/serverIdentity.ts +++ b/src/serverIdentity.ts @@ -1,3 +1,4 @@ +import { statSync } from "node:fs"; import { dirname, basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,6 +25,24 @@ const packageRoot = (): string => { : resolve(moduleDirectory, ".."); }; +const compareServerPaths = ( + expected: string, + observed: string, +): "aligned" | "mismatch" | "unknown" => { + if (expected === observed) return "aligned"; + if (process.platform === "win32") + return resolve(expected) === observed ? "aligned" : "mismatch"; + try { + const selected = statSync(expected, { bigint: true }); + const running = statSync(observed, { bigint: true }); + return selected.dev === running.dev && selected.ino === running.ino + ? "aligned" + : "mismatch"; + } catch { + return "unknown"; + } +}; + /** Build one runtime identity without claiming alignment from absent observations. */ export const createServerIdentity = (input: { readonly startedAt: string; @@ -35,6 +54,10 @@ export const createServerIdentity = (input: { }) => { const catalog = input.catalog; const serverPath = input.commandPath || undefined; + const pathComparison = + input.expected?.server_path === undefined || serverPath === undefined + ? "unknown" + : compareServerPaths(input.expected.server_path, serverPath); const reasons: string[] = []; if ( input.expected?.package_version !== undefined && @@ -46,17 +69,12 @@ export const createServerIdentity = (input: { input.expected.catalog_digest !== catalog.digests.combined_sha256 ) reasons.push("catalog_digest_mismatch"); - if ( - input.expected?.server_path !== undefined && - serverPath !== undefined && - resolve(input.expected.server_path) !== serverPath - ) - reasons.push("registration_path_mismatch"); + if (pathComparison === "mismatch") reasons.push("registration_path_mismatch"); const observedExpectation = input.expected !== undefined && Object.values(input.expected).some((value) => value !== undefined); const pathComparable = - input.expected?.server_path === undefined || serverPath !== undefined; + input.expected?.server_path === undefined || pathComparison !== "unknown"; return { package: { name: PRODUCT_IDENTITY.packageName, @@ -105,7 +123,10 @@ export const createServerIdentity = (input: { : input.expected?.server_path !== undefined && serverPath === undefined ? "The server entrypoint path is unavailable, so registration identity cannot be compared." - : "Supply expected package, catalog, or registration identity to compare the live server.", + : input.expected?.server_path !== undefined && + pathComparison === "unknown" + ? "The expected or running server entrypoint could not be inspected, so registration identity cannot be compared." + : "Supply expected package, catalog, or registration identity to compare the live server.", }, } as const; }; diff --git a/tests/boundary/mcp/serverIdentity.test.ts b/tests/boundary/mcp/serverIdentity.test.ts index a56758a791..94778aee45 100644 --- a/tests/boundary/mcp/serverIdentity.test.ts +++ b/tests/boundary/mcp/serverIdentity.test.ts @@ -1,6 +1,6 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { ok as resultOk } from "../../../src/domain/result.js"; -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -245,6 +245,43 @@ describe("server and catalog identity", () => { }); }); +it.skipIf(process.platform === "win32")( + "compares the selected entrypoint through symlink and parent traversal", + async () => { + const root = await createTestTempDirectory("server-identity-path-"); + const scripts = join(root, "package", "scripts"); + const scratch = join(root, "scratch"); + await mkdir(scripts, { recursive: true }); + await mkdir(scratch); + const canonical = join(scripts, "rea.mjs"); + const other = join(root, "other.mjs"); + await writeFile(canonical, "entrypoint"); + await writeFile(other, "other entrypoint"); + await symlink(scripts, join(scratch, "link")); + const selected = `${scratch}/link/../scripts/rea.mjs`; + const identity = (server_path: string) => + createServerIdentity({ + catalog: DEFAULT_CATALOG_IDENTITY, + startedAt: "2026-07-13T00:00:00.000Z", + commandPath: canonical, + expected: { server_path }, + }); + + expect(identity(selected).alignment).toMatchObject({ + state: "aligned", + reasons: [], + }); + expect(identity(other).alignment).toMatchObject({ + state: "mcp_server_restart_required", + reasons: ["registration_path_mismatch"], + }); + expect(identity(join(root, "missing.mjs")).alignment).toMatchObject({ + state: "unknown", + reasons: [], + }); + }, +); + describe("provider capability inventory", () => { it("reports composed, host, and target-specific availability truthfully", () => { const policy = {