diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index 2b3880b40..649129164 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -1,5 +1,5 @@ import Store from "electron-store"; -import { rotateLocalAuthToken } from "@executor-js/local"; +import { rotateLocalAuthToken } from "@executor-js/local/auth"; import { DEFAULT_SERVER_SETTINGS, type DesktopServerSettings } from "../shared/server-settings"; interface PersistedShape { diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 7ffbf8e48..5354277bc 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -23,7 +23,7 @@ import { parseExecutorLocalServerManifest, serializeExecutorLocalServerManifest, } from "@executor-js/sdk/shared"; -import { loadOrMintLocalAuthToken } from "@executor-js/local"; +import { loadOrMintLocalAuthToken } from "@executor-js/local/auth"; import { getServerSettings } from "./settings"; import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; diff --git a/apps/local/package.json b/apps/local/package.json index f05c29297..ca654f094 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./auth": "./src/auth.ts" }, "scripts": { "dev": "bun run dev:proxy && bun run dev:vite", diff --git a/e2e/scripts/verify-linux-desktop-attach.ts b/e2e/scripts/verify-linux-desktop-attach.ts new file mode 100644 index 000000000..b3faafb86 --- /dev/null +++ b/e2e/scripts/verify-linux-desktop-attach.ts @@ -0,0 +1,150 @@ +// Controlled, observable proof that the PACKAGED Linux desktop bundle attaches +// to an OS-supervised daemon — a manual harness (run by hand, not in the vitest +// matrix). Linux + Xvfb avoids the macOS Aqua-session limitation: the packaged +// Electron app runs headless under a virtual X server in a tart guest, so the +// attach path can be proven without a logged-in GUI session (which the +// desktop-packaged vitest project needs and skips without). Mirrors that test's +// intent, with a finally that always discards the VM. +// +// bun e2e/scripts/verify-linux-desktop-attach.ts /tmp/linux-desktop-bundle.tgz +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { tartVm } from "../src/vm/tart"; + +const execFileP = promisify(execFile); +const t0 = Date.now(); +const log = (m: string) => console.log(`[+${((Date.now() - t0) / 1000).toFixed(0)}s] ${m}`); + +const bundleTgz = process.argv[2] ?? "/tmp/linux-desktop-bundle.tgz"; +// Ubuntu 24.04 (noble) renamed several libs to `…t64`; apt fails the whole +// transaction if ANY name is missing, so install each separately and list both +// the pre-noble and t64 names — only the available one installs. +const DEPS = [ + "xvfb", + "libnss3", + "libnspr4", + "libatk1.0-0", + "libatk1.0-0t64", + "libatk-bridge2.0-0", + "libatk-bridge2.0-0t64", + "libcups2", + "libcups2t64", + "libdrm2", + "libxkbcommon0", + "libxcomposite1", + "libxdamage1", + "libxfixes3", + "libxrandr2", + "libgbm1", + "libpango-1.0-0", + "libcairo2", + "libasound2", + "libasound2t64", + "libatspi2.0-0", + "libatspi2.0-0t64", + "libgtk-3-0", + "libgtk-3-0t64", +]; + +const main = async () => { + log("provisioning tart linux guest..."); + const vm = await tartVm("linux", "arm64").provision(); + log(`provisioned host=${vm.host}`); + try { + log("pushing packaged bundle tarball..."); + await execFileP("sshpass", [ + "-p", + "admin", + "scp", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + bundleTgz, + `admin@${vm.host}:/tmp/app.tgz`, + ]); + log("extracting + installing Electron runtime deps (apt)..."); + await vm.ssh("mkdir -p /tmp/app && tar -xzf /tmp/app.tgz -C /tmp/app"); + await vm.ssh("sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq"); + // Install each package independently so one missing name (noble t64 rename) + // doesn't fail the whole transaction. + const apt = await vm.ssh( + `for p in ${DEPS.join(" ")}; do sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $p >/dev/null 2>&1 && echo "ok $p" || echo "skip $p"; done`, + ); + log(`apt:\n${apt.stdout.trim()}`); + const nss = await vm.ssh("ldconfig -p | grep -c libnss3 || true"); + log(`libnss3 present: ${nss.stdout.trim()}`); + + const home = "/tmp/eh"; + const exe = "/tmp/app/executor-desktop"; + const sidecar = "/tmp/app/resources/sidecar/executor-sidecar"; + const webui = "/tmp/app/resources/web-ui"; + + log("starting bundled sidecar as supervised daemon..."); + await vm.ssh( + `rm -rf ${home}; mkdir -p ${home}; nohup env HOME=${home} EXECUTOR_SUPERVISED=1 EXECUTOR_DATA_DIR=${home}/.executor EXECUTOR_PORT=4789 EXECUTOR_HOST=127.0.0.1 EXECUTOR_AUTH_TOKEN=linux-attach EXECUTOR_CLIENT_DIR=${webui} ${sidecar} > /tmp/daemon.log 2>&1 &`, + ); + // Wait for the daemon to publish its manifest + serve. + for (let i = 0; i < 30; i++) { + const r = await vm.ssh(`cat ${home}/.executor/server-control/server.json 2>/dev/null`); + if (r.stdout.includes("cli-daemon")) break; + await new Promise((res) => setTimeout(res, 1000)); + } + const before = (await vm.ssh(`cat ${home}/.executor/server-control/server.json`)).stdout.trim(); + const beforeManifest = JSON.parse(before) as { kind: string; pid: number }; + log(`daemon manifest: kind=${beforeManifest.kind} pid=${beforeManifest.pid}`); + if (beforeManifest.kind !== "cli-daemon") throw new Error("daemon did not publish cli-daemon"); + const health = await vm.ssh( + `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4789/api/health`, + ); + log(`/api/health = ${health.stdout.trim()}`); + + log("launching PACKAGED app under Xvfb..."); + await vm.ssh( + `nohup xvfb-run -a env HOME=${home} ELECTRON_ENABLE_LOGGING=1 ${exe} --no-sandbox > /tmp/app.log 2>&1 &`, + ); + + // Positive attach signal: the main process logs "attaching to supervised + // daemon" (sidecar.ts) — proves the attach CODE ran and succeeded, not just + // "the manifest is unchanged" (which a crash would also leave). + let attached = false; + for (let i = 0; i < 40; i++) { + const logs = await vm.ssh( + `cat /tmp/app.log ${home}/.config/Executor/logs/main.log 2>/dev/null`, + ); + if (/attaching to supervised daemon/i.test(logs.stdout)) { + attached = true; + break; + } + await new Promise((res) => setTimeout(res, 2000)); + } + const appLog = (await vm.ssh(`tail -40 /tmp/app.log 2>/dev/null`)).stdout.trim(); + log(`app.log tail:\n${appLog}`); + + const after = (await vm.ssh(`cat ${home}/.executor/server-control/server.json`)).stdout.trim(); + const afterManifest = JSON.parse(after) as { kind: string; pid: number }; + log(`manifest after app launch: kind=${afterManifest.kind} pid=${afterManifest.pid}`); + + if (!attached) { + throw new Error("no 'attaching to supervised daemon' log — app did not attach (see app.log)"); + } + if (afterManifest.kind !== "cli-daemon" || afterManifest.pid !== beforeManifest.pid) { + throw new Error( + `manifest changed (kind=${afterManifest.kind} pid=${afterManifest.pid}) — app spawned its own sidecar instead of attaching`, + ); + } + log("RESULT=PASS — packaged Linux app attached to the supervised daemon (Xvfb)"); + } finally { + log("discarding guest VM"); + await vm.discard(); + log("discarded"); + } +}; + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error("RESULT=FAIL", e); + process.exit(1); + });