diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts index f8dd7dc4e..03449fa0c 100644 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts @@ -97,6 +97,7 @@ describe("local daemon launch supervision", () => { await Promise.all( tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), ); + vi.restoreAllMocks(); }); test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index 4c3e6329c..b307a38a7 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -20,12 +20,18 @@ done SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) -# macOS: MacOS/Paseo is a sibling of Resources/ -# Linux: the executable sits next to resources/. Prefer Paseo.bin when present -# because Paseo is a GUI wrapper that adds Chromium flags not accepted by -# ELECTRON_RUN_AS_NODE. -if [ -x "${RESOURCES_DIR}/../MacOS/Paseo" ]; then - APP_EXECUTABLE="${RESOURCES_DIR}/../MacOS/Paseo" +MACOS_DIR="${RESOURCES_DIR}/../MacOS" +MACOS_HELPER_EXECUTABLE="${RESOURCES_DIR}/../Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper" + +# macOS CLI invocations must enter Electron through the agent Helper. Launching +# the main APPL executable here makes later daemon supervision inherit app UI +# lifecycle behavior. +if [ -d "${MACOS_DIR}" ]; then + if [ ! -x "${MACOS_HELPER_EXECUTABLE}" ]; then + echo "Bundled Paseo Helper executable not found at ${MACOS_HELPER_EXECUTABLE}" >&2 + exit 1 + fi + APP_EXECUTABLE="${MACOS_HELPER_EXECUTABLE}" elif [ -x "${RESOURCES_DIR}/../Paseo.bin" ]; then APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo.bin" elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then diff --git a/packages/desktop/scripts/smoke-packaged-desktop-app.js b/packages/desktop/scripts/smoke-packaged-desktop-app.js index d05efcf37..c7c983871 100644 --- a/packages/desktop/scripts/smoke-packaged-desktop-app.js +++ b/packages/desktop/scripts/smoke-packaged-desktop-app.js @@ -1,5 +1,6 @@ const { spawn, spawnSync } = require("node:child_process"); const fs = require("node:fs"); +const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); @@ -47,6 +48,10 @@ function getCliShimPath(appPath) { return path.join(appPath, "resources", "bin", "paseo"); } +function getMacMainExecutablePath(appPath) { + return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME); +} + function getLaunchCommand(executablePath) { if (process.platform !== "linux") { return { @@ -98,6 +103,108 @@ function createDefaultDaemonEnv(extraEnv) { return env; } +function reserveLocalTcpPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to reserve a TCP port for smoke test"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +async function waitForFile(filePath, label) { + const deadline = Date.now() + SMOKE_TIMEOUT_MS; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, "utf8"); + } + await delay(250); + } + throw new Error(`Timed out waiting for ${label}: ${filePath}`); +} + +async function waitForChildPids(parentPid) { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const childPids = listChildPids(parentPid); + if (childPids.length > 0) { + return childPids; + } + await delay(250); + } + throw new Error(`Timed out waiting for daemon worker child of supervisor PID ${parentPid}`); +} + +function listChildPids(parentPid) { + const result = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`ps failed while listing child processes: ${result.stderr.trim()}`); + } + + const children = []; + for (const line of result.stdout.split(/\r?\n/)) { + const [pidText, ppidText] = line.trim().split(/\s+/); + const pid = Number(pidText); + const ppid = Number(ppidText); + if (Number.isInteger(pid) && ppid === parentPid) { + children.push(pid); + } + } + return children; +} + +function listDarwinTextExecutables(pid) { + const result = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], { + encoding: "utf8", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `lsof failed while inspecting PID ${pid}: ${ + result.stderr.trim() || result.stdout.trim() || "" + }`, + ); + } + + return result.stdout + .split(/\r?\n/) + .filter((line) => line.startsWith("n")) + .map((line) => line.slice(1)); +} + +function assertDarwinProcessDoesNotUseMainAppExecutable({ appPath, pid, label }) { + const mainExecutablePath = getMacMainExecutablePath(appPath); + assertExecutable(mainExecutablePath, "Packaged app executable"); + + const textExecutables = listDarwinTextExecutables(pid); + if (textExecutables.includes(mainExecutablePath)) { + throw new Error( + `${label} PID ${pid} launched through the main app executable ${mainExecutablePath}.\nText executables:\n${textExecutables.join( + "\n", + )}`, + ); + } +} + function parseSmokeLine(line) { const prefix = "[paseo-smoke] "; if (!line.startsWith(prefix)) { @@ -370,6 +477,67 @@ async function smokeCliShim({ appPath, env }) { assertCleanDaemonStatusOutput(`${result.stdout}\n${result.stderr}`); } +async function smokeColdCliDaemonStart({ appPath }) { + const home = createTempDir("paseo-smoke-cli-daemon-home-"); + const pidPath = path.join(home, "paseo.pid"); + const port = await reserveLocalTcpPort(); + const listen = `127.0.0.1:${port}`; + const env = createDefaultDaemonEnv(); + + try { + console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim"); + await runCliShimCommand({ + appPath, + env, + args: [ + "daemon", + "start", + "--home", + home, + "--listen", + listen, + "--no-relay", + "--no-mcp", + "--no-inject-mcp", + ], + label: "Bundled CLI shim cold daemon start", + }); + + const pidInfo = JSON.parse(await waitForFile(pidPath, "cold CLI daemon pid file")); + if (!pidInfo || typeof pidInfo.pid !== "number") { + throw new Error(`Cold CLI daemon wrote invalid pid file: ${JSON.stringify(pidInfo)}`); + } + + if (process.platform === "darwin") { + assertDarwinProcessDoesNotUseMainAppExecutable({ + appPath, + pid: pidInfo.pid, + label: "Cold CLI daemon supervisor", + }); + const childPids = await waitForChildPids(pidInfo.pid); + for (const childPid of childPids) { + assertDarwinProcessDoesNotUseMainAppExecutable({ + appPath, + pid: childPid, + label: "Cold CLI daemon worker", + }); + } + } + } finally { + if (fs.existsSync(pidPath)) { + await runCliShimCommand({ + appPath, + env, + args: ["daemon", "stop", "--home", home, "--force"], + label: "Bundled CLI shim cold daemon stop", + }).catch((error) => { + console.warn(`Packaged desktop smoke: failed to stop cold CLI daemon: ${error}`); + }); + } + await removeTempDir(home); + } +} + function assertCleanDaemonStatusOutput(output) { const failureNeedles = [ "Get-CimInstance", @@ -466,6 +634,7 @@ async function stopCliDaemon({ appPath, env }) { async function smokePackagedDesktopApp({ appPath }) { const executablePath = getExecutablePath(appPath); assertExecutable(executablePath, "Packaged app executable"); + await smokeColdCliDaemonStart({ appPath }); const userData = createTempDir("paseo-smoke-user-data-"); const env = createDefaultDaemonEnv({ diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 8da28cc75..8676f0087 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -1,10 +1,67 @@ -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents, "utf8"); + chmodSync(filePath, 0o755); +} + +function createFakeMacBundle(options: { includeHelper: boolean }): { + root: string; + shimPath: string; +} { + const root = mkdtempSync(join(tmpdir(), "paseo-cli-shim-test-")); + const appPath = join(root, "Paseo.app"); + const contentsPath = join(appPath, "Contents"); + const resourcesPath = join(contentsPath, "Resources"); + const shimPath = join(resourcesPath, "bin", "paseo"); + const mainPath = join(contentsPath, "MacOS", "Paseo"); + const helperPath = join( + contentsPath, + "Frameworks", + "Paseo Helper.app", + "Contents", + "MacOS", + "Paseo Helper", + ); + + mkdirSync(dirname(shimPath), { recursive: true }); + mkdirSync(dirname(mainPath), { recursive: true }); + copyFileSync(join(packageRoot, "bin", "paseo"), shimPath); + chmodSync(shimPath, 0o755); + + writeExecutable(mainPath, "#!/bin/sh\necho main-executable\n"); + + if (options.includeHelper) { + mkdirSync(dirname(helperPath), { recursive: true }); + writeExecutable( + helperPath, + [ + "#!/bin/sh", + 'printf "helper env=%s/%s\\n" "$ELECTRON_RUN_AS_NODE" "$PASEO_NODE_ENV"', + 'printf "args=%s\\n" "$*"', + "", + ].join("\n"), + ); + } + + return { root, shimPath }; +} + describe("desktop packaging", () => { it("unpacks server zsh shell integration files for external shells", () => { const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); @@ -48,4 +105,38 @@ describe("desktop packaging", () => { expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); } }); + + it("launches the packaged macOS CLI through Helper instead of the main app executable", () => { + if (process.platform === "win32") return; + + const bundle = createFakeMacBundle({ includeHelper: true }); + try { + const result = spawnSync(bundle.shimPath, ["--version"], { encoding: "utf8" }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("helper env=1/production"); + expect(result.stdout).toContain("node-entrypoint-runner.js"); + expect(result.stdout).toContain("node-script"); + expect(result.stdout).toContain("@getpaseo/cli/dist/index.js"); + expect(result.stdout).toContain("--version"); + expect(result.stdout).not.toContain("main-executable"); + } finally { + rmSync(bundle.root, { recursive: true, force: true }); + } + }); + + it("fails packaged macOS CLI startup when Helper is missing", () => { + if (process.platform === "win32") return; + + const bundle = createFakeMacBundle({ includeHelper: false }); + try { + const result = spawnSync(bundle.shimPath, ["--version"], { encoding: "utf8" }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Bundled Paseo Helper executable not found"); + expect(result.stdout).not.toContain("main-executable"); + } finally { + rmSync(bundle.root, { recursive: true, force: true }); + } + }); });