diff --git a/src/cli/pack.ts b/src/cli/pack.ts index d2aba54..b4d0be9 100644 --- a/src/cli/pack.ts +++ b/src/cli/pack.ts @@ -206,15 +206,22 @@ export async function packExtension({ const zipFiles: Zippable = {}; const isUnix = process.platform !== "win32"; + const isBinaryType = manifest.server?.type === "binary"; + const binaryEntryPoint = isBinaryType ? manifest.server.entry_point : null; for (const [filePath, fileData] of Object.entries(files)) { if (isUnix) { + let mode = fileData.mode & 0o777; + // Ensure binary entry points are always executable in the ZIP, + // regardless of filesystem permissions. Windows-built ZIPs store + // 644 because Windows has no Unix execute bit — CD spawns binary + // entry points directly, so they must have +x to avoid EACCES. + if (binaryEntryPoint && filePath === binaryEntryPoint) { + mode = mode | 0o111; + } // Set external file attributes to preserve Unix permissions // The mode needs to be shifted to the upper 16 bits for ZIP format - zipFiles[filePath] = [ - fileData.data, - { os: 3, attrs: (fileData.mode & 0o777) << 16 }, - ]; + zipFiles[filePath] = [fileData.data, { os: 3, attrs: mode << 16 }]; } else { // On Windows, use default ZIP attributes (no Unix permissions) zipFiles[filePath] = fileData.data; diff --git a/test/cli.test.ts b/test/cli.test.ts index 7ecc402..907a783 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -357,5 +357,82 @@ describe("DXT CLI", () => { } } }); + + it("should add group+other execute bits on binary entry points during pack", () => { + // Skip this test on Windows since it doesn't support Unix permissions + if (process.platform === "win32") { + return; + } + + const tempBinDir = join(__dirname, "temp-binary-entry-test"); + const binPackedFilePath = join(__dirname, "test-binary-entry.dxt"); + const binUnpackedDir = join(__dirname, "temp-binary-entry-unpack"); + + try { + // Create a binary-type extension with owner-only execute (0o700) + // This passes validation (has some +x) but pack should add + // group+other execute bits for compatibility with CD extraction + fs.mkdirSync(join(tempBinDir, "server"), { recursive: true }); + fs.writeFileSync( + join(tempBinDir, "manifest.json"), + JSON.stringify({ + manifest_version: DEFAULT_MANIFEST_VERSION, + name: "Test Binary Extension", + version: "1.0.0", + description: "A test extension with binary entry point", + author: { + name: "MCPB", + }, + server: { + type: "binary", + entry_point: "server/myserver", + mcp_config: { + command: "${__dirname}/server/myserver", + }, + }, + }), + ); + + // Create binary entry point with 700 (owner-only execute) + fs.writeFileSync( + join(tempBinDir, "server", "myserver"), + "#!/bin/sh\necho binary", + ); + fs.chmodSync(join(tempBinDir, "server", "myserver"), 0o700); + + // Create a regular file for comparison (should stay 644) + fs.writeFileSync(join(tempBinDir, "config.json"), "{}"); + fs.chmodSync(join(tempBinDir, "config.json"), 0o644); + + // Pack the extension + execSync(`node ${cliPath} pack ${tempBinDir} ${binPackedFilePath}`, { + encoding: "utf-8", + }); + + // Unpack to check stored permissions + execSync( + `node ${cliPath} unpack ${binPackedFilePath} ${binUnpackedDir}`, + { + encoding: "utf-8", + }, + ); + + // Binary entry point should have full execute bits (700 | 111 = 711) + const entryStats = fs.statSync( + join(binUnpackedDir, "server", "myserver"), + ); + expect(entryStats.mode & 0o777).toBe(0o711); + + // Non-entry-point file should retain original permissions + const configStats = fs.statSync(join(binUnpackedDir, "config.json")); + expect(configStats.mode & 0o777).toBe(0o644); + } finally { + fs.rmSync(tempBinDir, { recursive: true, force: true }); + fs.rmSync(binUnpackedDir, { recursive: true, force: true }); + if (fs.existsSync(binPackedFilePath)) { + fs.unlinkSync(binPackedFilePath); + } + } + }); }); });