From fa9f936ef5cbbe4bd4f9890b638766fc92f45075 Mon Sep 17 00:00:00 2001 From: Diego Lumbreras Date: Fri, 9 Oct 2026 22:24:26 -0600 Subject: [PATCH] fix(desktop): sign Windows native addons (#8206) Co-authored-by: maria-rcks --- scripts/build-desktop-artifact.test.ts | 28 +++++++++++++++++--- scripts/build-desktop-artifact.ts | 36 +++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index ec2317b1ac926bd73f5598c97ab94c73eed29fb6..b17c51ce2dbacf66587ca7ddb3d08c19caafa3c6 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { extractFile } from "@electron/asar"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as FileSystem from "effect/FileSystem"; @@ -197,7 +198,7 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true }); yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n"); - yield* fs.writeFileString(nativePath, "native-binary"); + yield* fs.writeFileString(nativePath, "MZ native-binary"); const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE); yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath, arch: "x64" }); @@ -888,7 +889,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ]); }); - it.effect("keeps target native files while excluding the other Windows architecture", () => + it.effect("unpacks target Windows natives while excluding the other architecture", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -902,12 +903,14 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", + "node_modules/node-pty/prebuilds/win32-x64/pty.node", + "node_modules/node-pty/prebuilds/linux-x64/pty.node", ]; for (const nativeFile of nativeFiles) { const nativePath = path.join(sourceDir, nativeFile); yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); - yield* fs.writeFileString(nativePath, "native"); + yield* fs.writeFileString(nativePath, nativeFile.includes("linux") ? "\x7fELF" : "MZ"); } const asarPath = path.join(tempDir, "server.asar"); @@ -922,6 +925,22 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ), ); + assert.isTrue( + yield* fs.exists( + path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-x64/pty.node"), + ), + ); + // Signed builds sign every unpacked .node, so the ELF prebuild stays packed. + assert.isFalse( + yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64")), + ); + assert.equal( + extractFile( + asarPath, + path.join("node_modules", "node-pty", "prebuilds", "linux-x64", "pty.node"), + ).toString(), + "\x7fELF", + ); assert.isFalse( yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64")), ); @@ -2126,7 +2145,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); - it.effect("keeps executable resource editing enabled for unsigned Windows builds", () => + it.effect("configures Windows executable metadata and native signing extensions", () => Effect.gen(function* () { const config = yield* createBuildConfig( "win", @@ -2140,6 +2159,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { const win = config.win as Record; assert.equal(win.icon, "icon.ico"); + assert.deepStrictEqual(win.signExts, [".node", ".dll"]); assert.equal(win.signAndEditExecutable, true); assert.notProperty(win, "azureSignOptions"); }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 70781f471bd40a31ace993dd4543ebbbf0bd9467..837308f3b1f489d27c03fbc0d1e26de4a52c3cf9 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1028,6 +1028,14 @@ export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; // asar redirect convention). Everything else stays packed. export const WINDOWS_NATIVE_ASAR_UNPACK_GLOB = "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; +// The server sidecar unpacks .node files separately, keeping only Windows ones. +export const WINDOWS_SERVER_ASAR_UNPACK_GLOBS = [ + "**/*.dll", + "**/*.exe", + "**/*.so", + "**/*.so.*", + "**/*.dylib", +] as const; // Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK // platform packages are dead weight (see above), and node_modules/.bin shims // are never spawned at runtime (and are symlinks on POSIX build hosts, which @@ -2939,6 +2947,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const winConfig: Record = { target: [target], icon: "icon.ico", + // Smart App Control validates unpacked native addons independently from + // the signed desktop executable. Extend release signing to every native + // Windows library that Electron or the server sidecar loads at runtime. + signExts: [".node", ".dll"], // Resource editing applies the product metadata and icon independently // of code signing. Disabling it for local unsigned builds leaves the // packaged executable with Electron's stock icon. @@ -3027,11 +3039,33 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function readonly arch: typeof BuildArch.Type; }) { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Native addons are unpacked only when they are Windows PE ("MZ") binaries. + // Signed builds Authenticode-sign every unpacked .node file, which fails on + // the darwin and linux prebuilds some packages (node-pty) ship alongside + // their win32 ones. The Windows primary never loads those, so they stay packed. + const unpackGlobs: string[] = [...WINDOWS_SERVER_ASAR_UNPACK_GLOBS]; + for (const entry of yield* fs.readDirectory(input.sourceDir, { recursive: true })) { + if (!entry.endsWith(".node")) continue; + const addonPath = path.join(input.sourceDir, entry); + if ((yield* fs.stat(addonPath)).type !== "File") continue; + const bytes = yield* fs.readFile(addonPath); + if (bytes[0] !== 0x4d || bytes[1] !== 0x5a) continue; + const posixPath = entry.split(path.sep).join("/"); + // Backslash escapes are path separators to minimatch on Windows. + if (/[\\*?[\]{}(),]/.test(posixPath)) { + return yield* new WindowsServerSidecarPackError({ + asarPath: input.asarPath, + cause: new Error(`native addon path contains glob syntax: ${posixPath}`), + }); + } + unpackGlobs.push(`**/${posixPath}`); + } yield* Effect.tryPromise({ try: () => createPackageWithOptions(input.sourceDir, input.asarPath, { dot: true, - unpack: WINDOWS_NATIVE_ASAR_UNPACK_GLOB, + unpack: `{${unpackGlobs.join(",")}}`, // glob 13 (via @electron/asar 4) matches `ignore` relative to `cwd`, // not against the absolute paths it crawls, so anchor it at the source. globOptions: { -- 2.54.0