From c8cc8b5217978dfb293991eee5f5328b23e2a6ad Mon Sep 17 00:00:00 2001 From: waml <14224835+waml@users.noreply.github.com> Date: Sun, 17 May 2026 19:26:46 -0700 Subject: [PATCH] Fix Windows Electron launcher --- .github/workflows/ci.yml | 7 ++++++- CHANGELOG.md | 10 +++++++++ README.md | 13 ++++++++++++ bin/agentify-desktop.mjs | 32 +++++++++++++++++++++++----- context-packer.mjs | 6 +++++- mcp-lib.mjs | 42 ++++++++++++++++++++++--------------- package-lock.json | 4 ++-- package.json | 2 +- tests/bundle-store.test.mjs | 10 +++++---- tests/mcp-lib.test.mjs | 23 +++++++++++++------- 10 files changed, 111 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b945c6..de51fd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,11 @@ on: jobs: test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -14,4 +18,5 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: node bin/agentify-desktop.mjs --help - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6080f9c..49a65ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.3 - 2026-05-17 + +### Fixed +- Fixed the Windows npm/global GUI launcher path by running Electron through its package CLI with Node instead of the Windows `.cmd` shim. +- Applied the same safer Electron launch resolution to MCP desktop auto-start. + +### Changed +- Added Windows CI coverage for install/test and the npm CLI help path. +- Added README Windows notes for Chrome CDP and explicit browser executable configuration. + ## 0.2.2 - 2026-05-17 ### Fixed diff --git a/README.md b/README.md index f1f1d4c..b6f06ac 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,19 @@ Agentify Desktop does not bypass CAPTCHAs or use third-party solvers. If a verif If your account uses Google, Microsoft, or Apple SSO, keep auth popups enabled in the Control Center. If embedded login remains unreliable, use Chrome CDP. +## Windows Notes + +Use Node.js 20 or 22 on Windows. Agentify Desktop is tested against Windows in CI, including the npm CLI launcher path. + +Chrome CDP is still the recommended backend on Windows because Google and Microsoft SSO can block embedded Electron login. Agentify looks for Chrome, Chromium, Brave, and Microsoft Edge in the usual install locations and on `PATH`. + +If Chrome CDP cannot find your browser, set the executable explicitly: + +```powershell +$env:AGENTIFY_DESKTOP_CHROME_BIN = "C:\Program Files\Google\Chrome\Application\chrome.exe" +npx @agentify/desktop +``` + ## Local Data And Privacy Agentify Desktop is local-first: diff --git a/bin/agentify-desktop.mjs b/bin/agentify-desktop.mjs index fcad626..e1696b0 100755 --- a/bin/agentify-desktop.mjs +++ b/bin/agentify-desktop.mjs @@ -40,15 +40,36 @@ function resolveMode(invokedName, argv) { return { mode: 'unknown', args: argv }; } -function electronBin() { +function electronLaunch() { + const override = String(process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || '').trim(); + if (override) { + return { + command: override, + argsPrefix: [], + shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(override) + }; + } + + const electronCli = path.join(packageRoot, 'node_modules', 'electron', 'cli.js'); + if (fs.existsSync(electronCli)) { + return { command: process.execPath, argsPrefix: [electronCli], shell: false }; + } + const local = path.join( packageRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron' ); - if (fs.existsSync(local)) return local; - return process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || 'electron'; + if (fs.existsSync(local)) { + return { + command: local, + argsPrefix: [], + shell: process.platform === 'win32' + }; + } + + return { command: 'electron', argsPrefix: [], shell: process.platform === 'win32' }; } async function runMcp(args) { @@ -58,10 +79,11 @@ async function runMcp(args) { } function runGui(args) { - const child = spawn(electronBin(), [packageRoot, ...args], { + const launch = electronLaunch(); + const child = spawn(launch.command, [...launch.argsPrefix, packageRoot, ...args], { stdio: 'inherit', env: process.env, - shell: process.platform === 'win32' + shell: launch.shell }); child.on('error', (err) => { console.error(`agentify-desktop failed to start: ${err.message}`); diff --git a/context-packer.mjs b/context-packer.mjs index b10badc..6f40c24 100644 --- a/context-packer.mjs +++ b/context-packer.mjs @@ -58,6 +58,10 @@ function normalizeAbsoluteInputPath(value, { cwd = process.cwd() } = {}) { return path.isAbsolute(raw) ? raw : path.resolve(cwd, raw); } +function displayPath(filePath) { + return String(filePath || '').replace(/\\/g, '/'); +} + function looksBinaryByName(filePath) { return BINARY_EXTS.has(extnameLower(filePath)); } @@ -295,7 +299,7 @@ export async function prepareQueryContext({ for (const file of files) { const rel = roots.length ? path.relative(path.dirname(roots[0].path), file.absPath) : path.basename(file.absPath); - const named = rel && !rel.startsWith('..') ? rel : path.basename(file.absPath); + const named = displayPath(rel && !rel.startsWith('..') ? rel : path.basename(file.absPath)); if (looksBinaryByName(file.absPath)) { if (attachedFiles.length < maxAttachmentFiles && file.size <= maxBinaryAttachmentBytes && !attachedSet.has(file.absPath)) { attachedFiles.push({ path: file.absPath, reason: 'context-binary', size: file.size }); diff --git a/mcp-lib.mjs b/mcp-lib.mjs index 04d18e9..4167430 100644 --- a/mcp-lib.mjs +++ b/mcp-lib.mjs @@ -21,6 +21,28 @@ async function fileExists(p) { } } +async function electronLaunch({ platform, allowFallback = false }) { + const override = String(process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || '').trim(); + if (override) { + return { + command: override, + argsPrefix: [], + shell: platform === 'win32' && /\.(cmd|bat)$/i.test(override) + }; + } + + const electronCli = path.resolve(__dirname, 'node_modules', 'electron', 'cli.js'); + if (await fileExists(electronCli)) { + return { command: process.execPath, argsPrefix: [electronCli], shell: false }; + } + + if (allowFallback) { + return { command: 'electron', argsPrefix: [], shell: platform === 'win32' }; + } + + throw new Error('missing_electron_binary'); +} + export async function loadConnection({ stateDir }) { const state = await readState(stateDir); const token = await readToken(stateDir); @@ -80,25 +102,11 @@ export async function ensureDesktopRunning({ } } - const defaultElectronBin = path.resolve( - __dirname, - 'node_modules', - '.bin', - platform === 'win32' ? 'electron.cmd' : 'electron' - ); const entry = path.join(__dirname, 'main.mjs'); - const usingCustomSpawn = spawnImpl !== spawn; - let electronBin = defaultElectronBin; - if (!(await fileExists(electronBin))) { - if (usingCustomSpawn) { - electronBin = process.env.AGENTIFY_DESKTOP_ELECTRON_BIN || 'electron'; - } else { - throw new Error('missing_electron_binary'); - } - } + const launch = await electronLaunch({ platform, allowFallback: spawnImpl !== spawn }); if (!(await fileExists(entry))) throw new Error('missing_desktop_entry'); - spawnImpl(electronBin, [entry], { + spawnImpl(launch.command, [...launch.argsPrefix, entry], { detached: true, stdio: 'ignore', env: { @@ -106,7 +114,7 @@ export async function ensureDesktopRunning({ AGENTIFY_DESKTOP_STATE_DIR: stateDir, ...(showTabs ? { AGENTIFY_DESKTOP_SHOW_TABS: 'true' } : {}) }, - shell: platform === 'win32' + shell: launch.shell })?.unref?.(); const start = Date.now(); diff --git a/package-lock.json b/package-lock.json index f74af77..766e16f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentify/desktop", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentify/desktop", - "version": "0.2.2", + "version": "0.2.3", "license": "MPL-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index c8cef9b..7020cbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentify/desktop", - "version": "0.2.2", + "version": "0.2.3", "description": "Agentify Desktop control center and MCP server for local AI web sessions", "license": "MPL-2.0", "type": "module", diff --git a/tests/bundle-store.test.mjs b/tests/bundle-store.test.mjs index 82dcf90..9d0065b 100644 --- a/tests/bundle-store.test.mjs +++ b/tests/bundle-store.test.mjs @@ -56,6 +56,8 @@ test('bundle-store: ignores blank attachment/context entries', async () => { test('bundle-store: ignores legacy relative paths when reading persisted bundles', async () => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentify-bundles-legacy-relative-')); const bundleFile = path.join(stateDir, 'bundles.json'); + const absFile = path.join(stateDir, 'abs.txt'); + const absDir = path.join(stateDir, 'abs-dir'); await fs.writeFile( bundleFile, JSON.stringify( @@ -64,8 +66,8 @@ test('bundle-store: ignores legacy relative paths when reading persisted bundles { name: 'legacy', promptPrefix: 'Review carefully.', - attachments: ['./README.md', '/tmp/abs.txt'], - contextPaths: ['./src', '/tmp/abs-dir'] + attachments: ['./README.md', absFile], + contextPaths: ['./src', absDir] } ] }, @@ -76,8 +78,8 @@ test('bundle-store: ignores legacy relative paths when reading persisted bundles ); const got = await getBundle(stateDir, 'legacy'); - assert.equal(got?.attachments.includes('/tmp/abs.txt'), true); - assert.equal(got?.contextPaths.includes('/tmp/abs-dir'), true); + assert.equal(got?.attachments.includes(absFile), true); + assert.equal(got?.contextPaths.includes(absDir), true); assert.equal(got?.attachments.some((p) => !path.isAbsolute(p)), false); assert.equal(got?.contextPaths.some((p) => !path.isAbsolute(p)), false); assert.equal(got?.attachments.includes(path.resolve('./README.md')), false); diff --git a/tests/mcp-lib.test.mjs b/tests/mcp-lib.test.mjs index 45ea111..54124c2 100644 --- a/tests/mcp-lib.test.mjs +++ b/tests/mcp-lib.test.mjs @@ -105,7 +105,7 @@ test('mcp-lib: ensureDesktopRunning spawns if serverId mismatches and then recov assert.equal(conn.serverId, 'sid-new'); }); -test('mcp-lib: Windows spawn uses shell for electron.cmd', async () => { +test('mcp-lib: Windows spawn uses Node-hosted Electron CLI without shell', async () => { const dir = await tempDir(); const token = 't'; await ensureToken(dir); @@ -113,13 +113,17 @@ test('mcp-lib: Windows spawn uses shell for electron.cmd', async () => { await writeState({ ok: true, port: 12345, serverId: 'sid-old' }, dir); let fetchServerId = 'sid-wrong'; - let sawShell = false; + let spawnedCmd = null; + let spawnedArgs = null; + let spawnShell = null; const conn = await ensureDesktopRunning({ stateDir: dir, fetchImpl: makeFetch({ getServerId: () => fetchServerId, acceptToken: token }), platform: 'win32', - spawnImpl: (_cmd, _args, opts) => { - sawShell = opts?.shell === true; + spawnImpl: (cmd, args, opts) => { + spawnedCmd = cmd; + spawnedArgs = args; + spawnShell = opts?.shell; fetchServerId = 'sid-new'; void writeState({ ok: true, port: 12345, serverId: 'sid-new' }, dir); return { unref() {} }; @@ -127,7 +131,11 @@ test('mcp-lib: Windows spawn uses shell for electron.cmd', async () => { timeoutMs: 3000 }); assert.equal(conn.serverId, 'sid-new'); - assert.equal(sawShell, true); + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + assert.equal(spawnedCmd, process.execPath); + assert.equal(spawnedArgs?.[0], path.join(packageRoot, 'node_modules', 'electron', 'cli.js')); + assert.equal(spawnedArgs?.[1], path.join(packageRoot, 'main.mjs')); + assert.equal(spawnShell, false); }); test('mcp-lib: ensureDesktopRunning resolves bundled electron relative to desktop package, not cwd', async () => { @@ -157,8 +165,9 @@ test('mcp-lib: ensureDesktopRunning resolves bundled electron relative to deskto assert.equal(conn.serverId, 'sid-new'); assert.equal(path.isAbsolute(spawnedCmd), true); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - assert.equal(spawnedCmd, path.join(packageRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron')); - assert.equal(spawnedArgs?.[0], path.join(packageRoot, 'main.mjs')); + assert.equal(spawnedCmd, process.execPath); + assert.equal(spawnedArgs?.[0], path.join(packageRoot, 'node_modules', 'electron', 'cli.js')); + assert.equal(spawnedArgs?.[1], path.join(packageRoot, 'main.mjs')); } finally { process.chdir(originalCwd); }