From 078c9aea37409deeef4c4fc8baaab959c4852c6d Mon Sep 17 00:00:00 2001 From: Patodo Date: Thu, 17 Sep 2026 13:51:36 +0800 Subject: [PATCH 1/4] fix(windows): resolve the Job Object native helper outside the daemon bootstrap `localapp server run`, `localapp dev`, and browser opening all spawn owned process trees, which on Windows needs the packaged native helper for atomic Job Object ownership. The helper was resolved only from LOCALAPP_RELEASE_PATH, which just the daemon bootstrap exports, so a direct shell invocation always failed. Resolve it from LOCALAPP_RELEASE_PATH first, then from the CLI's own artifact directory (the parent of bin/localapp.mjs), and accept only a candidate that exists. When neither yields a helper, fail closed with the structured native_adapter_unsupported code; a bare Error was flattened to command_failed by the CLI entrypoint, hiding the cause. Reported symptom was `localapp server start` aborting with native_adapter_failed. Reproduced against the installed 0.2.1 helper: it wrote HKCU\Software\Classes\localapp and the Start Menu shortcut, then exited 0xC0000374. Clearing APPDATA returns exit 1 instead, which isolates the crash to the COM shortcut-identity step after the registry write, and 0.2.2 already fixed it (the PROPVARIANT drop freed a Rust Vec through CoTaskMemFree). Pin that fix so it cannot regress silently. --- README.md | 10 ++++- .../scripts/native-adapter.node-test.mjs | 13 +++++++ packages/localapp/src/artifact-directory.ts | 31 +++++++++++++++ packages/localapp/src/commands/server.ts | 12 +----- .../localapp/src/native/native-adapter.ts | 38 +++++++++++++++---- packages/localapp/src/process/process-tree.ts | 5 ++- .../localapp/tests/native-adapter.test.ts | 31 +++++++++++++++ packages/localapp/tests/process-tree.test.ts | 8 +++- .../localapp/tests/server-command.test.ts | 3 +- 9 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 packages/localapp/src/artifact-directory.ts diff --git a/README.md b/README.md index 0614131..514d27e 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ backend/ | 命令 | 说明 | | --- | --- | | `localapp server [start]` | 注册系统集成并启动当前用户 daemon | -| `localapp server run` | 以前台模式运行同一 Server,适合容器和服务管理器 | +| `localapp server run` | 以前台模式运行同一 Server,适合容器和服务管理器;Windows 上使用包内 native helper | | `localapp server stop/restart/status/logs/uninstall` | 管理当前用户 daemon | | `localapp init ` | 从 npm 包内置模板创建应用 | | `localapp build --package` | 构建并生成不含本地数据的 `.localapp` | @@ -270,6 +270,14 @@ docker load -i localapp-image.tar 仓库中的少量 Swift/Rust 代码仅用于编译这些按平台分发的 native adapter,不包含 Tauri、WebView、托盘 UI、CLI 或第二套 Server。 +Windows 上 `localapp` 需要 native adapter 提供原子 Job Object 子树所有权(`server run`、 +`dev` 与打开浏览器都经由它)。CLI 按以下顺序解析 helper,且只在文件确实存在时才采用, +否则以 `native_adapter_unsupported` 失败关闭: + +1. `LOCALAPP_RELEASE_PATH` 指向的 release 根(daemon 经 bootstrap 启动时由它导出); +2. CLI 自身所在的发行 artifact 目录(`bin/localapp.mjs` 的上一级)。因此直接从 shell + 执行 `localapp server run` 不需要任何额外环境变量。 + Windows 的用户发行物仍是标准 npm tgz。完整的 native adapter 构建、签名、打包 检查和干净环境验收流程见: diff --git a/packages/localapp/scripts/native-adapter.node-test.mjs b/packages/localapp/scripts/native-adapter.node-test.mjs index 11bb4fa..82c12c3 100644 --- a/packages/localapp/scripts/native-adapter.node-test.mjs +++ b/packages/localapp/scripts/native-adapter.node-test.mjs @@ -183,6 +183,19 @@ test("Windows helper preserves argv, has an explicit application path, and keeps assert.match(source, /ShellExecuteW/); }); +test("Windows helper hands the shortcut property store a task-allocator buffer, never a Rust allocation", async () => { + // Break caught: `--register` wrote the Scheme registry key and then died with + // STATUS_HEAP_CORRUPTION before the CLI could install the daemon, because the + // PROPVARIANT drop cleared a Vec pointer through CoTaskMemFree. + const source = await fs.readFile(path.join(repositoryRoot, "packages/localapp/native/windows/src/main.rs"), "utf8"); + assert.match(source, /CoTaskMemAlloc\(app_id_wide\.len\(\) \* std::mem::size_of::\(\)\)/); + assert.match(source, /pwszVal: PWSTR\(allocation\.cast::\(\)\)/); + assert.doesNotMatch(source, /pwszVal: PWSTR\(app_id_wide/); + assert.doesNotMatch(source, /let mut app_id_wide = wide\(/); + const copy = source.indexOf("copy_nonoverlapping(app_id_wide.as_ptr(), allocation.cast::()"); + assert.ok(copy > 0 && copy < source.indexOf("SetValue(&APP_USER_MODEL_KEY")); +}); + function run(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: false }); diff --git a/packages/localapp/src/artifact-directory.ts b/packages/localapp/src/artifact-directory.ts new file mode 100644 index 0000000..1d2614b --- /dev/null +++ b/packages/localapp/src/artifact-directory.ts @@ -0,0 +1,31 @@ +import { realpathSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * The packaged artifact this CLI belongs to: the directory holding `runtime/` + * and `.localapp-artifact.json`. A published install exposes the entrypoint as + * `bin/localapp.mjs`, directly or through an npm bin shim, so the artifact root + * is always the entrypoint's parent directory. + */ +export function artifactDirectoryFromEntrypoint(entrypoint: string): string { + const canonical = realpathSync(entrypoint); + return path.resolve(path.dirname(canonical), ".."); +} + +/** + * The artifact directory this process runs from. `argv[1]` is the real + * entrypoint for every CLI invocation; the module-relative root keeps + * programmatic imports (and an unresolvable entrypoint) working instead of + * failing with a bare ENOENT. + */ +export function localAppArtifactDirectory(entrypoint: string | undefined = process.argv[1]): string { + if (entrypoint !== undefined && entrypoint.length > 0) { + try { + return artifactDirectoryFromEntrypoint(entrypoint); + } catch { + // Not a resolvable entrypoint; the module-relative root is authoritative. + } + } + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +} diff --git a/packages/localapp/src/commands/server.ts b/packages/localapp/src/commands/server.ts index 5dea19f..430165c 100644 --- a/packages/localapp/src/commands/server.ts +++ b/packages/localapp/src/commands/server.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; -import { realpathSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { localAppArtifactDirectory } from "../artifact-directory.js"; import { lifecycleError } from "../errors.js"; import { createIpcClient, type IpcClient } from "../daemon/ipc-client.js"; import { publishRelease, readCurrentRelease, verifyReleaseArtifact, type CurrentRelease } from "../daemon/release-store.js"; @@ -186,14 +185,7 @@ function defaultServiceManager(layout: RuntimeLayout): ServiceManager { } function defaultArtifactDirectory(): string { - const fromEntry = process.argv[1] === undefined ? undefined : artifactDirectoryFromEntrypoint(process.argv[1]); - if (fromEntry !== undefined) return fromEntry; - return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -} - -export function artifactDirectoryFromEntrypoint(entrypoint: string): string { - const canonical = realpathSync(entrypoint); - return path.resolve(path.dirname(canonical), ".."); + return localAppArtifactDirectory(); } export function runtimeLayoutFromEnvironment(environment: NodeJS.ProcessEnv = process.env): RuntimeLayout { diff --git a/packages/localapp/src/native/native-adapter.ts b/packages/localapp/src/native/native-adapter.ts index 1987775..486609a 100644 --- a/packages/localapp/src/native/native-adapter.ts +++ b/packages/localapp/src/native/native-adapter.ts @@ -1,9 +1,11 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import type { WindowsOwnedProcessHandle, WindowsProcessTreeAdapter } from "../process/process-tree.js"; import { lifecycleError } from "../errors.js"; +import { localAppArtifactDirectory } from "../artifact-directory.js"; import { ACTIVATION_URL_LIMIT_BYTES } from "../activation/activation-url.js"; import { selectNativeAdapter } from "./adapter-selection.js"; @@ -309,7 +311,7 @@ export function createWindowsProcessTreeAdapter(helper: WindowsNativeHelper): Wi export function createWindowsProcessTreeAdapterFromEnvironment(): WindowsProcessTreeAdapter | undefined { if (process.platform !== "win32") return undefined; - const executable = windowsNativeExecutableFromEnvironment(); + const executable = resolveWindowsNativeExecutable(); if (executable === undefined) return undefined; return createWindowsProcessTreeAdapter({ spawn(command, args, options) { @@ -323,6 +325,32 @@ export function createWindowsProcessTreeAdapterFromEnvironment(): WindowsProcess }); } +export interface WindowsNativeExecutableOptions { + env?: NodeJS.ProcessEnv; + arch?: string; + artifactDirectory?: string; +} + +/** + * The Windows helper is a build product of a release artifact, so it is + * resolved from a release root and never from PATH. The daemon bootstrap + * exports LOCALAPP_RELEASE_PATH; the CLI's own artifact directory covers every + * direct invocation (`localapp server run`, browser opening) that never passes + * through that bootstrap. A candidate that is not on disk is not an adapter, so + * callers still fail closed instead of spawning an unowned process tree. + */ +export function resolveWindowsNativeExecutable(options: WindowsNativeExecutableOptions = {}): string | undefined { + const env = options.env ?? process.env; + const arch = options.arch ?? process.arch; + const roots = [env.LOCALAPP_RELEASE_PATH, options.artifactDirectory ?? localAppArtifactDirectory()]; + for (const root of roots) { + if (root === undefined || root.length === 0) continue; + const candidate = path.join(root, "runtime", "native", `win32-${arch}`, "localapp-native.exe"); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + export async function openValidatedExternalUrl(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { throw lifecycleError("browser_open_invalid", "The validated browser destination is invalid"); } @@ -332,7 +360,7 @@ export async function openValidatedExternalUrl(url: string): Promise { if (process.platform === "darwin") return waitForChild(spawn("/usr/bin/open", [url], { shell: false, stdio: "ignore" })); if (process.platform === "linux") return waitForChild(spawn("xdg-open", [url], { shell: false, stdio: "ignore" })); if (process.platform === "win32") { - const executable = windowsNativeExecutableFromEnvironment(); + const executable = resolveWindowsNativeExecutable(); if (executable === undefined) throw lifecycleError("native_adapter_unsupported", "NATIVE_ADAPTER_UNSUPPORTED: the Windows opener is unavailable"); await waitForChild(spawn(executable, ["--open-url", url], { shell: false, stdio: "ignore", windowsHide: true })); return; @@ -340,12 +368,6 @@ export async function openValidatedExternalUrl(url: string): Promise { throw unsupported(process.platform); } -function windowsNativeExecutableFromEnvironment(): string | undefined { - const release = process.env.LOCALAPP_RELEASE_PATH; - if (!release) return undefined; - return path.join(release, "runtime", "native", `win32-${process.arch}`, "localapp-native.exe"); -} - async function runCommand(command: string, args: readonly string[], timeoutMs: number, environment: NodeJS.ProcessEnv = process.env): Promise { const child = spawn(command, [...args], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: environment }); let stdout = ""; diff --git a/packages/localapp/src/process/process-tree.ts b/packages/localapp/src/process/process-tree.ts index e8ee6d8..f0a5253 100644 --- a/packages/localapp/src/process/process-tree.ts +++ b/packages/localapp/src/process/process-tree.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess, type SpawnOptions, type StdioOptions } from "node:child_process"; import { createWindowsProcessTreeAdapterFromEnvironment } from "../native/native-adapter.js"; +import { lifecycleError } from "../errors.js"; export interface OwnedProcessExit { code: number | null; @@ -73,7 +74,9 @@ export function spawnOwnedProcess( if (platform === "win32") { const windowsAdapter = options.windowsAdapter ?? createWindowsProcessTreeAdapterFromEnvironment(); if (windowsAdapter === undefined) { - throw new Error("Windows process-tree adapter is unavailable; refusing to spawn without atomic Job Object ownership"); + // A structured code keeps the cause visible; a bare Error is flattened to + // command_failed by the CLI entrypoint. + throw lifecycleError("native_adapter_unsupported", "NATIVE_ADAPTER_UNSUPPORTED: the Windows process-tree adapter is unavailable without the packaged LocalApp native helper"); } return ownedWindowsProcess(windowsAdapter.spawnOwned(command, args, spawnOptions), options); } diff --git a/packages/localapp/tests/native-adapter.test.ts b/packages/localapp/tests/native-adapter.test.ts index f05e5e0..aa055c3 100644 --- a/packages/localapp/tests/native-adapter.test.ts +++ b/packages/localapp/tests/native-adapter.test.ts @@ -13,6 +13,7 @@ import { createWindowsSchemeForwardInvocation, installLinuxScheme, performWindowsAtomicOwnership, + resolveWindowsNativeExecutable, validateNativeNotificationEnvelope, } from "../src/native/native-adapter.js"; @@ -305,6 +306,36 @@ function notificationEnvelope(iconPath: string) { }; } +describe("Windows native helper resolution", () => { + it("prefers the bootstrap release root, then the CLI's own artifact directory", async () => { + const artifactRoot = await windowsHelperFixture(true); + const releaseRoot = await windowsHelperFixture(true); + expect(resolveWindowsNativeExecutable({ env: { LOCALAPP_RELEASE_PATH: releaseRoot }, arch: "x64", artifactDirectory: artifactRoot })) + .toBe(path.join(releaseRoot, "runtime", "native", "win32-x64", "localapp-native.exe")); + // Break caught: `localapp server run` and browser opening never pass through + // the daemon bootstrap, so LOCALAPP_RELEASE_PATH is absent for a user shell. + expect(resolveWindowsNativeExecutable({ env: {}, arch: "x64", artifactDirectory: artifactRoot })) + .toBe(path.join(artifactRoot, "runtime", "native", "win32-x64", "localapp-native.exe")); + }); + + it("yields no adapter when neither release root holds an installed helper", async () => { + const artifactRoot = await windowsHelperFixture(false); + expect(resolveWindowsNativeExecutable({ env: { LOCALAPP_RELEASE_PATH: path.join(artifactRoot, "releases/0.0.0-absent") }, arch: "x64", artifactDirectory: artifactRoot })).toBeUndefined(); + expect(resolveWindowsNativeExecutable({ env: {}, arch: "x64", artifactDirectory: artifactRoot })).toBeUndefined(); + }); +}); + +async function windowsHelperFixture(writeExecutable: boolean): Promise { + const root = await fs.mkdtemp(path.resolve(process.cwd(), "../../tmp/task-11-native-resolution-")); + fixtureDirectories.push(root); + if (writeExecutable) { + const executable = path.join(root, "runtime", "native", "win32-x64", "localapp-native.exe"); + await fs.mkdir(path.dirname(executable), { recursive: true }); + await fs.writeFile(executable, ""); + } + return root; +} + async function nativeFixture(platform: "darwin" | "win32" | "linux") { const root = await fs.mkdtemp(path.resolve(process.cwd(), "../../tmp/task-11-native-adapter-")); fixtureDirectories.push(root); diff --git a/packages/localapp/tests/process-tree.test.ts b/packages/localapp/tests/process-tree.test.ts index 1c01cb2..a32650f 100644 --- a/packages/localapp/tests/process-tree.test.ts +++ b/packages/localapp/tests/process-tree.test.ts @@ -324,10 +324,14 @@ describe("owned process trees", () => { it("fails closed on Windows when the suspended-root Job Object adapter is unavailable", () => { // Break caught: ordinary Windows spawn races descendants before Task 8 can assign the root to a Job Object. - expect(() => spawnOwnedProcess(process.execPath, ["-e", "process.exit(0)"], { + const spawn = () => spawnOwnedProcess(process.execPath, ["-e", "process.exit(0)"], { platform: "win32", stdio: "ignore", - })).toThrow(/Windows process-tree adapter.*unavailable/i); + }); + expect(spawn).toThrow(/Windows process-tree adapter.*unavailable/i); + // Break caught: an untyped Error is flattened to command_failed at the CLI + // entrypoint, hiding the real cause from `localapp server run`. + expect(spawn).toThrow(expect.objectContaining({ code: "native_adapter_unsupported" })); }); }); diff --git a/packages/localapp/tests/server-command.test.ts b/packages/localapp/tests/server-command.test.ts index 8dd7d7c..bbbd1d8 100644 --- a/packages/localapp/tests/server-command.test.ts +++ b/packages/localapp/tests/server-command.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { artifactDirectoryFromEntrypoint, runServerCommand, runtimeLayoutFromEnvironment } from "../src/commands/server.js"; +import { artifactDirectoryFromEntrypoint } from "../src/artifact-directory.js"; +import { runServerCommand, runtimeLayoutFromEnvironment } from "../src/commands/server.js"; import type { RuntimeLayout } from "../src/daemon/runtime-layout.js"; const layout = { From 7fe2a4e14ada896dcf386d4b71487d7e17ccf73b Mon Sep 17 00:00:00 2001 From: Patodo Date: Thu, 17 Sep 2026 14:07:57 +0800 Subject: [PATCH 2/4] chore: re-review the public source baseline entry for native-adapter tests The public source gate pins each credential-scan exception to the reviewed file content by sha256, so editing packages/localapp/tests/native-adapter.test.ts invalidated its exception and failed the export gate. The only credential-keyed assignment in that file is unchanged (`LOCALAPP_API_KEY` in the redaction fixture) before and after the edit, so the entry is re-reviewed with the new digest rather than narrowed or dropped. --- scripts/public-source-scan-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/public-source-scan-baseline.json b/scripts/public-source-scan-baseline.json index 81dcb9b..c3f552e 100644 --- a/scripts/public-source-scan-baseline.json +++ b/scripts/public-source-scan-baseline.json @@ -34,7 +34,7 @@ { "path": "packages/localapp/tests/native-adapter.test.ts", "rule": "POSSIBLE_CREDENTIAL", - "sha256": "4f1c411c9d690c1b35443883380a874d162a87b2612285d3cc5efbc064abe56f" + "sha256": "8f93accbef08d81cce76efbd35d724883a52174776d50eeac3efe3d308ff4229" }, { "path": "packages/localapp/tests/notification-manager.test.ts", From 943cbda8723e14ae8b2f89660184e5b6c4f334ac Mon Sep 17 00:00:00 2001 From: Patodo Date: Thu, 17 Sep 2026 15:47:28 +0800 Subject: [PATCH 3/4] fix(ci): build the Platform Shell before the deterministic suite The Platform regression workflow never built packages/web, so verification-isolation ran against a Server whose Platform Shell static export was missing. The app entry then answered 404, and the check reported a missing shell as a failed verification boundary: all 46 runs of this workflow failed, back to the commit that introduced it. ci.yml builds packages/web before running the same suite, which is why the identical check was green there. Confirmed by building packages/web locally: verification-sessions.test.ts goes from 1 failed to 8 passed with no product change. The suite's build prerequisites are now asserted so the workflow cannot silently drop them again, and documented in the benchmark README. --- .github/workflows/platform-regression.yml | 6 ++++++ benchmarks/agent-first-run/README.md | 7 +++++++ scripts/release-workflow.node-test.mjs | 11 +++++++++++ 3 files changed, 24 insertions(+) diff --git a/.github/workflows/platform-regression.yml b/.github/workflows/platform-regression.yml index defa37c..24aa0bc 100644 --- a/.github/workflows/platform-regression.yml +++ b/.github/workflows/platform-regression.yml @@ -20,4 +20,10 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm -C packages/server-core build + + # verification-isolation serves the production Platform Shell, so the + # Server reads the Next.js static export at packages/web/out. Without that + # export the app entry answers 404 and the check fails for a missing + # shell, not for a verification-boundary defect. + - run: pnpm -C packages/web build - run: pnpm test:platform-regression diff --git a/benchmarks/agent-first-run/README.md b/benchmarks/agent-first-run/README.md index e73b6f0..a12bac5 100644 --- a/benchmarks/agent-first-run/README.md +++ b/benchmarks/agent-first-run/README.md @@ -12,9 +12,16 @@ The catalog contains six stable requirements. Do not change an existing requirem Run the deterministic platform suite without an Agent or paid API: ```bash +pnpm -C packages/server-core build +pnpm -C packages/web build pnpm test:platform-regression ``` +Both builds are prerequisites. `verification-isolation` drives the production +app entry, which serves the Platform Shell from the Next.js static export at +`packages/web/out`; without it the entry answers `404` and the check fails for a +missing shell instead of a verification-boundary defect. + It checks the capability contract, dev/production content behavior, production verification isolation, and the benchmark protocol. This is the only benchmark layer that blocks ordinary CI. ## Controlled Agent Run diff --git a/scripts/release-workflow.node-test.mjs b/scripts/release-workflow.node-test.mjs index 1017345..b73381b 100644 --- a/scripts/release-workflow.node-test.mjs +++ b/scripts/release-workflow.node-test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; const release = fs.readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); const ci = fs.readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); const windows = fs.readFileSync(new URL("../.github/workflows/native-windows.yml", import.meta.url), "utf8"); +const platformRegression = fs.readFileSync(new URL("../.github/workflows/platform-regression.yml", import.meta.url), "utf8"); const dockerfile = fs.readFileSync(new URL("../Dockerfile", import.meta.url), "utf8"); const dockerSmoke = fs.readFileSync(new URL("./docker-release-smoke.sh", import.meta.url), "utf8"); const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8"); @@ -48,6 +49,16 @@ test("Windows workflow builds only the localapp native adapter", () => { assert.doesNotMatch(windows, /packages\/cli|packages\/desktop|@localapp\/desktop|tauri|nsis/i); }); +test("platform regression builds the Platform Shell before the deterministic suite", () => { + // Break caught: without packages/web/out the Server answers 404 for the app + // entry, so verification-isolation reported a missing shell as a failed + // verification boundary on every run of this workflow. + const shellBuild = platformRegression.indexOf("pnpm -C packages/web build"); + const suite = platformRegression.indexOf("pnpm test:platform-regression"); + assert.ok(shellBuild >= 0, "the Platform Shell must be built for the deterministic suite"); + assert.ok(suite > shellBuild, "the Platform Shell must be built before the deterministic suite runs"); +}); + test("Docker installs the packed npm product and runs its public server command", () => { assert.match(dockerfile, /^FROM node:24-slim AS runtime$/m); assert.match(dockerfile, /COPY tmp\/localapp-package\/localapp-\*\.tgz \/dist\/localapp\.tgz/); From 2c741a33df14edd47de89756bb05914de9d78429 Mon Sep 17 00:00:00 2001 From: Patodo Date: Thu, 17 Sep 2026 15:56:42 +0800 Subject: [PATCH 4/4] test: catch stale public source baseline digests before CI does The export gate pins every reviewed scan exception to the digest of the file it pins, so touching a baselined file fails the gate until the digest is refreshed. That cost two CI round trips on this branch because nothing reported it locally. Add a check that every baseline exception still matches its file. It hashes LF-normalized content because the export hashes committed content, while a Windows checkout materializes the same file with CRLF; the normalization is a no-op on Linux. Verified on the real baseline: all 29 exceptions match, and the check reports the two stale digests created by editing the test that carries it. Refresh those digests, plus the one for scripts/release-workflow.node-test.mjs that the workflow guard commit invalidated. The flagged content is unchanged in each file: the baseline review covers the same canonical package assertions and the same credential fixture as before. --- scripts/export-public-source.node-test.mjs | 15 +++++++++++++++ scripts/public-source-scan-baseline.json | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/export-public-source.node-test.mjs b/scripts/export-public-source.node-test.mjs index aa249a7..23aeb6f 100644 --- a/scripts/export-public-source.node-test.mjs +++ b/scripts/export-public-source.node-test.mjs @@ -258,3 +258,18 @@ test("default Server tests exclude generated-app acceptance while preserving its assert.match(serverConfig, /tests\/e2e-unified\/real-apps\.spec\.ts/); assert.match(rootManifest.scripts["test:real-apps"], /real-apps\.spec\.ts/); }); + +test("every reviewed baseline exception still matches the file it pins", () => { + // Break caught: editing a baselined file leaves its reviewed digest stale, and + // the export gate only reports that after a full CI round trip. + const baseline = JSON.parse(fs.readFileSync(new URL("./public-source-scan-baseline.json", import.meta.url), "utf8")); + const stale = []; + for (const exception of baseline.exceptions) { + const bytes = fs.readFileSync(new URL(`../${exception.path}`, import.meta.url)); + // The export hashes committed content, which is LF on every platform, while a + // Windows checkout materializes the same file with CRLF. + const digest = createHash("sha256").update(bytes.toString("binary").replace(/\r\n/g, "\n"), "binary").digest("hex"); + if (digest !== exception.sha256) stale.push(`${exception.rule} ${exception.path}`); + } + assert.deepEqual(stale, [], `refresh the recorded sha256 for: ${stale.join(", ")}`); +}); diff --git a/scripts/public-source-scan-baseline.json b/scripts/public-source-scan-baseline.json index c3f552e..cba2473 100644 --- a/scripts/public-source-scan-baseline.json +++ b/scripts/public-source-scan-baseline.json @@ -134,17 +134,17 @@ { "path": "scripts/export-public-source.node-test.mjs", "rule": "PRIVATE_TEST_IDENTITY", - "sha256": "4962a560d89bb0dcf2f5128f0657b3e44bb325c4c69c283ba962056a03afbb29" + "sha256": "fe915223346cc448802b60ca80e0533e3b116e5661b8a73b2d1b0b2c5187dade" }, { "path": "scripts/export-public-source.node-test.mjs", "rule": "POSSIBLE_CREDENTIAL", - "sha256": "4962a560d89bb0dcf2f5128f0657b3e44bb325c4c69c283ba962056a03afbb29" + "sha256": "fe915223346cc448802b60ca80e0533e3b116e5661b8a73b2d1b0b2c5187dade" }, { "path": "scripts/release-workflow.node-test.mjs", "rule": "PRIVATE_TEST_IDENTITY", - "sha256": "2efd147a93e8b6142fbd31f9cd1bdf11376821b4ea613e62441851878bedbf1b" + "sha256": "e1f74009acb91907a2442db90d8ac343f3e9eddb02e365600c310d1c68604f63" } ] }