diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index cef79295..d2139928 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -101,7 +101,7 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: cd apps/desktop && npm run notarize:mac:dmg - - name: Validate macOS release artifacts + - name: Validate macOS release artifacts and runtime payloads run: cd apps/desktop && npm run validate:mac:artifacts - name: Upload validated artifacts to workflow run diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d35a2f84..d04d1ff1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,8 @@ "author": "Arul Sharma", "license": "AGPL-3.0", "scripts": { - "predev": "node ./scripts/clear-vite-cache.cjs", + "predev": "node ./scripts/clear-vite-cache.cjs && node ./scripts/normalize-runtime-binaries.cjs", + "prebuild": "node ./scripts/normalize-runtime-binaries.cjs", "dev": "node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs", "build": "tsup && vite build", "dist:mac": "npm run build && electron-builder --mac --publish never", @@ -130,13 +131,16 @@ "electron.cjs", "package.json", "vendor/**/*", - "!node_modules/onnxruntime-node/**", - "!node_modules/node-pty/build/**" + "!node_modules/onnxruntime-node/**" ], "asarUnpack": [ + "dist/main/adeMcpProxy.cjs", + "dist/main/packagedRuntimeSmoke.cjs", + "node_modules/node-pty/**/*", "node_modules/@huggingface/transformers/node_modules/onnxruntime-node/**", "vendor/crsqlite/**" ], + "afterPack": "./scripts/after-pack-runtime-fixes.cjs", "publish": { "provider": "github", "owner": "arul28", diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs new file mode 100644 index 00000000..bf6ef744 --- /dev/null +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -0,0 +1,35 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + normalizeDesktopRuntimeBinaries, + resolvePackagedRuntimeRoot, +} = require("./runtimeBinaryPermissions.cjs"); + +module.exports = async function afterPack(context) { + const productFilename = context?.packager?.appInfo?.productFilename || "ADE"; + const appBundlePath = path.join(context?.appOutDir || "", `${productFilename}.app`); + if (!appBundlePath || !fs.existsSync(appBundlePath)) { + throw new Error(`[afterPack] Missing packaged app bundle: ${String(appBundlePath)}`); + } + + const runtimeRoot = resolvePackagedRuntimeRoot(appBundlePath); + if (!fs.existsSync(runtimeRoot)) { + throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); + } + + const normalized = normalizeDesktopRuntimeBinaries(runtimeRoot); + for (const entry of normalized) { + console.log(`[afterPack] Restored executable mode: ${entry.label} -> ${path.relative(appBundlePath, entry.filePath)}`); + } + + const requiredScripts = [ + path.join(runtimeRoot, "dist", "main", "adeMcpProxy.cjs"), + path.join(runtimeRoot, "dist", "main", "packagedRuntimeSmoke.cjs"), + ]; + + for (const scriptPath of requiredScripts) { + if (!fs.existsSync(scriptPath)) { + throw new Error(`[afterPack] Missing unpacked runtime entry: ${scriptPath}`); + } + } +}; diff --git a/apps/desktop/scripts/normalize-runtime-binaries.cjs b/apps/desktop/scripts/normalize-runtime-binaries.cjs new file mode 100644 index 00000000..f668bdd6 --- /dev/null +++ b/apps/desktop/scripts/normalize-runtime-binaries.cjs @@ -0,0 +1,14 @@ +const path = require("node:path"); +const { normalizeDesktopRuntimeBinaries } = require("./runtimeBinaryPermissions.cjs"); + +const appDir = path.resolve(__dirname, ".."); +const normalized = normalizeDesktopRuntimeBinaries(appDir); + +if (normalized.length === 0) { + console.log("[runtime-binaries] No executable mode fixes were needed."); + process.exit(0); +} + +for (const entry of normalized) { + console.log(`[runtime-binaries] Restored executable mode: ${entry.label} -> ${path.relative(appDir, entry.filePath)}`); +} diff --git a/apps/desktop/scripts/prepare-universal-mac-inputs.mjs b/apps/desktop/scripts/prepare-universal-mac-inputs.mjs index 9e230beb..2813c669 100644 --- a/apps/desktop/scripts/prepare-universal-mac-inputs.mjs +++ b/apps/desktop/scripts/prepare-universal-mac-inputs.mjs @@ -43,6 +43,50 @@ async function assertPathExists(targetPath, description) { } } +async function findFirstNodeAddon(rootPath) { + const entries = await fs.readdir(rootPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name); + + if (entry.isDirectory()) { + const nestedMatch = await findFirstNodeAddon(entryPath); + if (nestedMatch) { + return nestedMatch; + } + continue; + } + + if (entry.isFile() && entry.name.endsWith(".node")) { + return entryPath; + } + } + + return null; +} + +async function findNodePtyAddon(moduleRootPath) { + const candidateRoots = [ + path.join(moduleRootPath, "build", "Release"), + path.join(moduleRootPath, "build", "Debug"), + path.join(moduleRootPath, "prebuilds", "darwin-arm64"), + path.join(moduleRootPath, "prebuilds", "darwin-x64"), + ]; + + for (const candidateRoot of candidateRoots) { + if (!(await pathExists(candidateRoot))) { + continue; + } + + const addonPath = await findFirstNodeAddon(candidateRoot); + if (addonPath) { + return addonPath; + } + } + + return null; +} + async function loadPackageLock() { return JSON.parse(await fs.readFile(packageLockPath, "utf8")); } @@ -246,6 +290,14 @@ async function assertUniversalInputsReady() { path.join(appDir, "vendor", "crsqlite", "darwin-x64", "crsqlite.dylib"), "x64 crsqlite dylib", ); + await assertPathExists(path.join(appDir, "node_modules", "node-pty"), "node-pty package"); + const nodePtyAddon = await findNodePtyAddon(path.join(appDir, "node_modules", "node-pty")); + if (!nodePtyAddon) { + throw new Error( + "[release:mac] Missing node-pty native addon under node_modules/node-pty/build or node_modules/node-pty/prebuilds", + ); + } + console.log(`[release:mac] Verified node-pty native addon: ${path.relative(appDir, nodePtyAddon)}`); } const x64App = await resolveX64AppPath(); @@ -257,11 +309,6 @@ try { await seedFromLockfileAndPinnedArtifacts(); } - await fs.rm(path.join(appDir, "node_modules", "node-pty", "build"), { - recursive: true, - force: true, - }); - await assertUniversalInputsReady(); console.log("[release:mac] Universal macOS source tree now contains the required x64 runtime payloads"); } finally { diff --git a/apps/desktop/scripts/release-mac-local.mjs b/apps/desktop/scripts/release-mac-local.mjs index 866d989f..1307442c 100644 --- a/apps/desktop/scripts/release-mac-local.mjs +++ b/apps/desktop/scripts/release-mac-local.mjs @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -76,13 +76,23 @@ function run(command, args, env) { function maybeUseInstalledIdentity(env) { const hasImportedCertificate = Boolean(env.CSC_LINK && env.CSC_KEY_PASSWORD); + const cscLinkPath = typeof env.CSC_LINK === "string" && env.CSC_LINK.startsWith("/") ? env.CSC_LINK : null; + const missingImportedCertificate = Boolean(cscLinkPath && !existsSync(cscLinkPath)); - if (hasImportedCertificate) { + if (hasImportedCertificate && !missingImportedCertificate) { delete env.CSC_NAME; return; } if (env.CSC_NAME) { + if (missingImportedCertificate) { + console.warn( + `[release:mac] CSC_LINK points to a missing certificate file (${cscLinkPath}); ` + + `falling back to installed identity ${env.CSC_NAME}.`, + ); + delete env.CSC_LINK; + delete env.CSC_KEY_PASSWORD; + } return; } @@ -110,6 +120,11 @@ function maybeUseInstalledIdentity(env) { identities.find((identity) => authorName && identity.includes(authorName)) ?? identities[0] ?? null; if (!preferredIdentity) { + if (missingImportedCertificate) { + throw new Error( + `[release:mac] CSC_LINK points to a missing certificate file (${cscLinkPath}) and no installed Developer ID Application identity was found`, + ); + } return; } diff --git a/apps/desktop/scripts/require-macos-release-secrets.cjs b/apps/desktop/scripts/require-macos-release-secrets.cjs index 0da69479..42f8a79f 100644 --- a/apps/desktop/scripts/require-macos-release-secrets.cjs +++ b/apps/desktop/scripts/require-macos-release-secrets.cjs @@ -1,6 +1,8 @@ #!/usr/bin/env node "use strict"; +const fs = require("node:fs"); + function hasEnv(name) { return Boolean(process.env[name] && String(process.env[name]).trim().length > 0); } @@ -13,8 +15,14 @@ const missing = []; const hasImportedCertificate = ["CSC_LINK", "CSC_KEY_PASSWORD"].every(hasEnv); const hasInstalledIdentity = hasEnv("CSC_NAME"); +const cscLink = hasEnv("CSC_LINK") ? String(process.env.CSC_LINK).trim() : ""; +const cscLinkIsAbsolutePath = cscLink.startsWith("/"); +const cscLinkPathExists = !cscLinkIsAbsolutePath || fs.existsSync(cscLink); -if (!hasImportedCertificate && !hasInstalledIdentity) { +if (hasImportedCertificate && cscLinkIsAbsolutePath && !cscLinkPathExists && !hasInstalledIdentity) { + missing.push(`CSC_LINK points to a missing certificate file: ${cscLink}`); + missing.push("Provide a valid CSC_LINK + CSC_KEY_PASSWORD pair or set CSC_NAME to an installed Developer ID identity"); +} else if (!hasImportedCertificate && !hasInstalledIdentity) { missing.push("Provide either CSC_LINK + CSC_KEY_PASSWORD or CSC_NAME"); } @@ -67,8 +75,16 @@ if (matchingProfile.vars.includes("APPLE_API_KEY") && !String(process.env.APPLE_ process.exit(1); } +if (hasImportedCertificate && cscLinkIsAbsolutePath && !cscLinkPathExists && hasInstalledIdentity) { + process.stdout.write( + `[release:mac] CSC_LINK points to a missing file (${cscLink}); continuing with installed identity ${process.env.CSC_NAME}.\n` + ); +} + process.stdout.write( `[release:mac] macOS signing and notarization environment looks complete (` + - `${hasImportedCertificate ? "imported Developer ID certificate" : `installed identity ${process.env.CSC_NAME}`}, ` + + `${hasImportedCertificate && (!cscLinkIsAbsolutePath || cscLinkPathExists) + ? "imported Developer ID certificate" + : `installed identity ${process.env.CSC_NAME}`}, ` + `${matchingProfile.label}).\n` ); diff --git a/apps/desktop/scripts/runtimeBinaryPermissions.cjs b/apps/desktop/scripts/runtimeBinaryPermissions.cjs new file mode 100644 index 00000000..c7022a87 --- /dev/null +++ b/apps/desktop/scripts/runtimeBinaryPermissions.cjs @@ -0,0 +1,125 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const EXECUTABLE_MASK = 0o111; +const NODE_PTY_HELPER_PATH_PATCHES = [ + { + from: "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + to: "helperPath = helperPath.replace(/app\\.asar(?!\\.unpacked)/, 'app.asar.unpacked');", + }, + { + from: "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + to: "helperPath = helperPath.replace(/node_modules\\.asar(?!\\.unpacked)/, 'node_modules.asar.unpacked');", + }, +]; + +function pathExists(targetPath) { + try { + fs.accessSync(targetPath); + return true; + } catch { + return false; + } +} + +function listDirectories(rootPath) { + if (!pathExists(rootPath)) return []; + return fs.readdirSync(rootPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name)); +} + +function ensureExecutable(filePath) { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + const currentMode = stat.mode & 0o777; + if ((currentMode & EXECUTABLE_MASK) === EXECUTABLE_MASK) { + return false; + } + fs.chmodSync(filePath, currentMode | EXECUTABLE_MASK); + return true; +} + +function normalizeFileSet(filePaths, label) { + const normalized = []; + for (const filePath of filePaths) { + if (!pathExists(filePath)) continue; + if (ensureExecutable(filePath)) normalized.push(filePath); + } + return normalized.map((filePath) => ({ filePath, label })); +} + +function collectDesktopRuntimeExecutableCandidates(rootPath) { + const candidates = []; + + for (const prebuildDir of listDirectories(path.join(rootPath, "node_modules", "node-pty", "prebuilds"))) { + candidates.push({ + filePath: path.join(prebuildDir, "spawn-helper"), + label: "node-pty spawn helper", + }); + } + + for (const packageDir of listDirectories(path.join(rootPath, "node_modules", "@openai"))) { + if (!path.basename(packageDir).startsWith("codex-darwin-")) continue; + for (const vendorDir of listDirectories(path.join(packageDir, "vendor"))) { + candidates.push({ + filePath: path.join(vendorDir, "codex", "codex"), + label: "Codex CLI binary", + }); + candidates.push({ + filePath: path.join(vendorDir, "path", "rg"), + label: "Codex ripgrep helper", + }); + } + } + + for (const vendorDir of listDirectories(path.join(rootPath, "node_modules", "@anthropic-ai", "claude-agent-sdk", "vendor", "ripgrep"))) { + candidates.push({ + filePath: path.join(vendorDir, "rg"), + label: "Claude ripgrep helper", + }); + } + + return candidates; +} + +function normalizeDesktopRuntimeBinaries(rootPath) { + const normalized = []; + for (const candidate of collectDesktopRuntimeExecutableCandidates(rootPath)) { + if (!pathExists(candidate.filePath)) continue; + if (ensureExecutable(candidate.filePath)) { + normalized.push(candidate); + } + } + + const helperPathFiles = [ + path.join(rootPath, "node_modules", "node-pty", "lib", "unixTerminal.js"), + path.join(rootPath, "node_modules", "node-pty", "src", "unixTerminal.ts"), + ]; + + for (const filePath of helperPathFiles) { + if (!pathExists(filePath)) continue; + const original = fs.readFileSync(filePath, "utf8"); + let updated = original; + for (const patch of NODE_PTY_HELPER_PATH_PATCHES) { + updated = updated.replace(patch.from, patch.to); + } + if (updated === original) continue; + fs.writeFileSync(filePath, updated, "utf8"); + normalized.push({ + filePath, + label: "node-pty helper path patch", + }); + } + + return normalized; +} + +function resolvePackagedRuntimeRoot(appBundlePath) { + return path.join(appBundlePath, "Contents", "Resources", "app.asar.unpacked"); +} + +module.exports = { + normalizeDesktopRuntimeBinaries, + resolvePackagedRuntimeRoot, +}; diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index a5db3927..2e92f2cb 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -39,6 +39,86 @@ async function assertPathExists(targetPath, description) { } } +async function assertExecutable(targetPath, description) { + const stat = await fs.stat(targetPath); + if ((stat.mode & 0o111) !== 0o111) { + throw new Error(`[release:mac] Expected ${description} to be executable: ${targetPath}`); + } +} + +async function findFirstNodeAddon(rootPath) { + const entries = await fs.readdir(rootPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name); + + if (entry.isDirectory()) { + const nestedMatch = await findFirstNodeAddon(entryPath); + if (nestedMatch) { + return nestedMatch; + } + continue; + } + + if (entry.isFile() && entry.name.endsWith(".node")) { + return entryPath; + } + } + + return null; +} + +async function findNodePtyAddon(moduleRootPath) { + const candidateRoots = [ + path.join(moduleRootPath, "build", "Release"), + path.join(moduleRootPath, "build", "Debug"), + path.join(moduleRootPath, "prebuilds", "darwin-arm64"), + path.join(moduleRootPath, "prebuilds", "darwin-x64"), + ]; + + for (const candidateRoot of candidateRoots) { + try { + await fs.access(candidateRoot); + } catch { + continue; + } + + const addonPath = await findFirstNodeAddon(candidateRoot); + if (addonPath) { + return addonPath; + } + } + + return null; +} + +async function findNodePtySpawnHelper(moduleRootPath) { + const candidateRoots = [ + path.join(moduleRootPath, "build", "Release"), + path.join(moduleRootPath, "build", "Debug"), + path.join(moduleRootPath, "prebuilds", "darwin-arm64"), + path.join(moduleRootPath, "prebuilds", "darwin-x64"), + ]; + + for (const candidateRoot of candidateRoots) { + try { + await fs.access(candidateRoot); + } catch { + continue; + } + + const helperPath = path.join(candidateRoot, "spawn-helper"); + try { + await fs.access(helperPath); + return helperPath; + } catch { + // keep looking + } + } + + return null; +} + async function findArtifact(regex, description) { const entries = await fs.readdir(releaseDir, { withFileTypes: true }); const matches = entries @@ -68,6 +148,83 @@ async function validateSignedApp(appPath, description) { await execFileAsync("spctl", ["-a", "-vvv", "--type", "execute", appPath]); } +async function validatePackagedRuntime(appPath, description) { + const appName = path.basename(appPath, ".app"); + const executablePath = path.join(appPath, "Contents", "MacOS", appName); + const resourcesPath = path.join(appPath, "Contents", "Resources"); + const appAsarPath = path.join(resourcesPath, "app.asar"); + const unpackedPath = path.join(resourcesPath, "app.asar.unpacked"); + const nodeModulesPath = path.join(unpackedPath, "node_modules"); + const nodePtyModulePath = path.join(nodeModulesPath, "node-pty"); + const smokeScriptPath = path.join(unpackedPath, "dist", "main", "packagedRuntimeSmoke.cjs"); + const adeMcpProxyPath = path.join(unpackedPath, "dist", "main", "adeMcpProxy.cjs"); + + console.log(`[release:mac] Smoke testing packaged runtime payload for ${description}`); + await assertPathExists(executablePath, "packaged app executable"); + await assertPathExists(appAsarPath, "app.asar payload"); + await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); + await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); + await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); + await assertPathExists(adeMcpProxyPath, "unpacked ADE MCP proxy script"); + + const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); + if (!nodePtyAddon) { + throw new Error(`[release:mac] Missing node-pty native addon under ${nodePtyModulePath}`); + } + const nodePtySpawnHelper = await findNodePtySpawnHelper(nodePtyModulePath); + if (!nodePtySpawnHelper) { + throw new Error(`[release:mac] Missing node-pty spawn-helper under ${nodePtyModulePath}`); + } + await assertExecutable(nodePtySpawnHelper, "node-pty spawn-helper"); + + const { stdout } = await execFileAsync(executablePath, [smokeScriptPath], { + cwd: unpackedPath, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: nodeModulesPath, + }, + }); + + const payload = JSON.parse(stdout.trim()); + if (payload?.nodePty !== "function") { + throw new Error(`[release:mac] Packaged smoke expected node-pty.spawn to be a function, got ${String(payload?.nodePty)}`); + } + if (!payload?.ptyProbe?.ok) { + throw new Error("[release:mac] Packaged smoke failed to execute a PTY probe"); + } + if (payload?.claudeQuery !== "function") { + throw new Error(`[release:mac] Packaged smoke expected Claude SDK query() to be available, got ${String(payload?.claudeQuery)}`); + } + if (typeof payload?.claudeExecutablePath !== "string" || payload.claudeExecutablePath.trim().length === 0) { + throw new Error("[release:mac] Packaged smoke did not report a Claude executable path"); + } + if (payload.claudeExecutablePath.includes("app.asar")) { + throw new Error( + `[release:mac] Packaged smoke resolved Claude to an asar-backed path instead of the system CLI: ${payload.claudeExecutablePath}` + ); + } + if (!payload?.claudeStartup || typeof payload.claudeStartup !== "object") { + throw new Error("[release:mac] Packaged smoke did not report a Claude startup result"); + } + if (payload.claudeStartup.state === "runtime-failed") { + throw new Error( + `[release:mac] Packaged smoke could not start Claude from the packaged app: ${String(payload.claudeStartup.message || "unknown error")}` + ); + } + if (payload?.codexFactory !== "function") { + throw new Error(`[release:mac] Packaged smoke expected Codex provider factory to be available, got ${String(payload?.codexFactory)}`); + } + if (payload?.launchMode !== "bundled_proxy") { + throw new Error(`[release:mac] Packaged smoke expected bundled_proxy launch mode, got ${String(payload?.launchMode)}`); + } + if (!payload?.proxyProbe?.ok) { + throw new Error("[release:mac] Packaged smoke failed to launch the bundled ADE MCP proxy in probe mode"); + } + + console.log(`[release:mac] Packaged runtime smoke passed for ${description}: ${path.relative(appPath, nodePtyAddon)}`); +} + async function validateLatestMacYaml(latestMacPath, zipPath) { await assertPathExists(latestMacPath, "latest-mac.yml"); const latestMac = parseYaml(await fs.readFile(latestMacPath, "utf8")); @@ -110,6 +267,7 @@ async function validateZip(zipPath) { const appPath = path.join(tempDir, appEntry.name); await validateSignedApp(appPath, "zip artifact"); + await validatePackagedRuntime(appPath, "zip artifact"); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -139,6 +297,7 @@ async function validateDmg(dmgPath) { const appPath = path.join(mountPoint, "ADE.app"); await assertPathExists(appPath, "mounted ADE.app"); await validateSignedApp(appPath, "mounted dmg artifact"); + await validatePackagedRuntime(appPath, "mounted dmg artifact"); } finally { await execFileAsync("hdiutil", ["detach", mountPoint, "-quiet"]).catch(() => {}); await fs.rm(mountPoint, { recursive: true, force: true }); @@ -169,6 +328,6 @@ if (dmgPath) { } console.log( - `[release:mac] macOS release artifacts passed signature, notarization, Gatekeeper, and updater checks` + + `[release:mac] macOS release artifacts passed signature, notarization, Gatekeeper, updater, and packaged runtime checks` + (skipDmg ? " (DMG validation skipped)" : "") ); diff --git a/apps/desktop/src/main/adeMcpProxy.ts b/apps/desktop/src/main/adeMcpProxy.ts new file mode 100644 index 00000000..61c1de3b --- /dev/null +++ b/apps/desktop/src/main/adeMcpProxy.ts @@ -0,0 +1,261 @@ +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { Buffer } from "node:buffer"; +import { resolveAdeLayout } from "../shared/adeLayout"; + +process.env.ADE_STDIO_TRANSPORT ??= "1"; + +type RuntimeRoots = { + projectRoot: string; + workspaceRoot: string; +}; + +type ProxyIdentity = { + missionId: string | null; + runId: string | null; + stepId: string | null; + attemptId: string | null; + role: string | null; +}; + +type ParsedInboundMessage = { + transport: "jsonl" | "framed"; + payloadText: string; + raw: Buffer; + rest: Buffer; +}; + +function resolveCliArg(flag: string): string | null { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const value = args[i]; + if (value !== flag) continue; + const next = args[i + 1]; + if (next?.trim()) return path.resolve(next.trim()); + } + return null; +} + +function hasFlag(flag: string): boolean { + return process.argv.slice(2).includes(flag); +} + +function resolveRuntimeRoots(): RuntimeRoots { + const projectRoot = (() => { + const fromEnv = process.env.ADE_PROJECT_ROOT?.trim(); + if (fromEnv) return path.resolve(fromEnv); + return resolveCliArg("--project-root") ?? process.cwd(); + })(); + + const workspaceRoot = (() => { + const fromEnv = process.env.ADE_WORKSPACE_ROOT?.trim(); + if (fromEnv) return path.resolve(fromEnv); + return resolveCliArg("--workspace-root") ?? projectRoot; + })(); + + return { + projectRoot, + workspaceRoot, + }; +} + +function asTrimmed(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function resolveProxyIdentityFromEnv(): ProxyIdentity { + return { + missionId: asTrimmed(process.env.ADE_MISSION_ID), + runId: asTrimmed(process.env.ADE_RUN_ID), + stepId: asTrimmed(process.env.ADE_STEP_ID), + attemptId: asTrimmed(process.env.ADE_ATTEMPT_ID), + role: asTrimmed(process.env.ADE_DEFAULT_ROLE), + }; +} + +function hasProxyIdentity(identity: ProxyIdentity): boolean { + return Boolean(identity.missionId || identity.runId || identity.stepId || identity.attemptId || identity.role); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function findHeaderBoundary(buffer: Buffer): { index: number; delimiterLength: number } | null { + const crlf = buffer.indexOf("\r\n\r\n", 0, "utf8"); + const lf = buffer.indexOf("\n\n", 0, "utf8"); + if (crlf === -1 && lf === -1) return null; + if (crlf === -1) return { index: lf, delimiterLength: 2 }; + if (lf === -1) return { index: crlf, delimiterLength: 4 }; + return crlf < lf ? { index: crlf, delimiterLength: 4 } : { index: lf, delimiterLength: 2 }; +} + +function parseContentLength(headerBlock: string): number | null { + const lines = headerBlock.split(/\r?\n/); + for (const line of lines) { + const match = /^content-length\s*:\s*(\d+)\s*$/i.exec(line.trim()); + if (!match) continue; + return Number.parseInt(match[1] ?? "", 10); + } + return null; +} + +function takeNextInboundMessage(buffer: Buffer): ParsedInboundMessage | null { + if (!buffer.length) return null; + const first = buffer[0]!; + + if (first === 0x7b || first === 0x5b) { + const newline = buffer.indexOf(0x0a); + if (newline === -1) return null; + const raw = buffer.subarray(0, newline + 1); + const payloadText = buffer.subarray(0, newline).toString("utf8").trim(); + return { + transport: "jsonl", + payloadText, + raw, + rest: buffer.subarray(newline + 1), + }; + } + + const boundary = findHeaderBoundary(buffer); + if (!boundary) return null; + const headerBlock = buffer.subarray(0, boundary.index).toString("utf8"); + const contentLength = parseContentLength(headerBlock); + if (contentLength == null || contentLength < 0) return null; + const bodyStart = boundary.index + boundary.delimiterLength; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) return null; + + return { + transport: "framed", + payloadText: buffer.subarray(bodyStart, bodyEnd).toString("utf8"), + raw: buffer.subarray(0, bodyEnd), + rest: buffer.subarray(bodyEnd), + }; +} + +function injectIdentityIntoInitializePayload(payloadText: string, identity: ProxyIdentity): string { + if (!hasProxyIdentity(identity)) return payloadText; + let payload: unknown; + try { + payload = JSON.parse(payloadText); + } catch { + return payloadText; + } + if (!isRecord(payload) || payload.method !== "initialize") { + return payloadText; + } + + const params = isRecord(payload.params) ? { ...payload.params } : {}; + const existingIdentity = isRecord(params.identity) ? { ...params.identity } : {}; + const mergedIdentity: Record = { ...existingIdentity }; + + if ((!isRecord(existingIdentity) || typeof existingIdentity.missionId !== "string" || !existingIdentity.missionId.trim()) && identity.missionId) { + mergedIdentity.missionId = identity.missionId; + } + if ((!isRecord(existingIdentity) || typeof existingIdentity.runId !== "string" || !existingIdentity.runId.trim()) && identity.runId) { + mergedIdentity.runId = identity.runId; + } + if ((!isRecord(existingIdentity) || typeof existingIdentity.stepId !== "string" || !existingIdentity.stepId.trim()) && identity.stepId) { + mergedIdentity.stepId = identity.stepId; + } + if ((!isRecord(existingIdentity) || typeof existingIdentity.attemptId !== "string" || !existingIdentity.attemptId.trim()) && identity.attemptId) { + mergedIdentity.attemptId = identity.attemptId; + } + if ((!isRecord(existingIdentity) || typeof existingIdentity.role !== "string" || !existingIdentity.role.trim()) && identity.role) { + mergedIdentity.role = identity.role; + } + + return JSON.stringify({ + ...payload, + params: { + ...params, + identity: mergedIdentity, + }, + }); +} + +function relayProxyInputWithIdentity(socket: net.Socket): void { + const identity = resolveProxyIdentityFromEnv(); + if (!hasProxyIdentity(identity)) { + process.stdin.pipe(socket); + process.stdin.on("end", () => { + socket.end(); + process.exit(0); + }); + return; + } + + let pending: Buffer = Buffer.alloc(0); + process.stdin.on("data", (chunk: Buffer) => { + pending = Buffer.concat([pending, Buffer.from(chunk)]); + while (true) { + const parsed = takeNextInboundMessage(pending); + if (!parsed) break; + pending = parsed.rest; + const payloadText = injectIdentityIntoInitializePayload(parsed.payloadText, identity); + if (payloadText === parsed.payloadText) { + socket.write(parsed.raw); + continue; + } + if (parsed.transport === "jsonl") { + socket.write(`${payloadText}\n`); + continue; + } + const framed = `Content-Length: ${Buffer.byteLength(payloadText, "utf8")}\r\n\r\n${payloadText}`; + socket.write(framed); + } + }); + process.stdin.on("end", () => { + if (pending.length > 0) { + socket.write(pending); + pending = Buffer.alloc(0); + } + socket.end(); + process.exit(0); + }); +} + +async function main(): Promise { + const roots = resolveRuntimeRoots(); + const socketPath = process.env.ADE_MCP_SOCKET_PATH?.trim() || resolveAdeLayout(roots.projectRoot).socketPath; + + if (hasFlag("--probe")) { + process.stdout.write(JSON.stringify({ + ok: true, + mode: "bundled_proxy", + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath, + socketExists: fs.existsSync(socketPath), + })); + process.exit(0); + } + + const socket = net.createConnection(socketPath); + let connected = false; + + socket.on("error", (err) => { + const prefix = connected ? "[ade-mcp-proxy]" : "[ade-mcp-proxy] Failed to connect"; + process.stderr.write(`${prefix}: ${err.message}\n`); + process.exit(1); + }); + + socket.on("connect", () => { + connected = true; + process.stdin.resume(); + relayProxyInputWithIdentity(socket); + socket.pipe(process.stdout); + }); + + socket.on("close", () => { + process.exit(connected ? 0 : 1); + }); +} + +void main().catch((error) => { + process.stderr.write(`[ade-mcp-proxy] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 5ae15251..f0862931 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -113,8 +113,10 @@ function fixElectronShellPath(): void { if (process.platform !== "darwin" && process.platform !== "linux") return; const currentPath = process.env.PATH ?? ""; + const hasUserLocalBin = currentPath.includes(".local/bin"); + const hasCommonCliBin = currentPath.includes("/usr/local/bin") || currentPath.includes("/opt/homebrew/bin"); // Already rich — likely launched from terminal or already fixed. - if (currentPath.includes("/usr/local/bin") && currentPath.includes(".local/bin")) return; + if (hasUserLocalBin && hasCommonCliBin) return; try { const loginShell = process.env.SHELL || "/bin/zsh"; diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts new file mode 100644 index 00000000..8f1753ca --- /dev/null +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -0,0 +1,198 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { promisify } from "node:util"; +import { resolveDesktopAdeMcpLaunch } from "./services/runtime/adeMcpLaunch"; +import { resolveClaudeCodeExecutable } from "./services/ai/claudeCodeExecutable"; + +const execFileAsync = promisify(execFile); +const PTY_PROBE_TIMEOUT_MS = 4_000; +const CLAUDE_PROBE_TIMEOUT_MS = 20_000; + +function isClaudeAuthFailureMessage(input: unknown): boolean { + const text = input instanceof Error ? input.message : String(input ?? ""); + const lower = text.toLowerCase(); + return ( + lower.includes("not authenticated") + || lower.includes("not logged in") + || lower.includes("authentication required") + || lower.includes("authentication error") + || lower.includes("authentication_error") + || lower.includes("login required") + || lower.includes("sign in") + || lower.includes("claude auth login") + || lower.includes("/login") + || lower.includes("authentication_failed") + || lower.includes("invalid authentication credentials") + || lower.includes("invalid api key") + || lower.includes("api error: 401") + || lower.includes("status code: 401") + || lower.includes("status 401") + ); +} + +async function probePty(): Promise<{ ok: true; output: string }> { + const pty = await import("node-pty"); + return await new Promise((resolve, reject) => { + let output = ""; + const term = pty.spawn("/bin/sh", ["-lc", 'printf "ADE_PTY_OK\\n"'], { + name: "xterm-256color", + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env }, + }); + + const timeout = setTimeout(() => { + try { + term.kill(); + } catch { + // ignore best-effort cleanup + } + reject(new Error("PTY probe timed out")); + }, PTY_PROBE_TIMEOUT_MS); + + term.onData((chunk) => { + output += chunk; + }); + term.onExit((event) => { + clearTimeout(timeout); + if (!output.includes("ADE_PTY_OK")) { + reject(new Error(`PTY probe exited without expected output (exit=${event.exitCode ?? "null"})`)); + return; + } + resolve({ ok: true, output }); + }); + }); +} + +async function probeClaudeStartup( + claudeExecutablePath: string, +): Promise< + | { state: "ready"; message: null } + | { state: "auth-failed"; message: string } + | { state: "runtime-failed"; message: string } +> { + const claude = await import("@anthropic-ai/claude-agent-sdk"); + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), CLAUDE_PROBE_TIMEOUT_MS); + const stream = claude.query({ + prompt: "System initialization check. Respond with only the word READY.", + options: { + cwd: process.cwd(), + permissionMode: "plan", + tools: [], + pathToClaudeCodeExecutable: claudeExecutablePath, + abortController, + }, + }); + + try { + for await (const message of stream) { + if (message.type === "auth_status" && message.error) { + return { state: "auth-failed", message: message.error }; + } + if (message.type === "assistant" && message.error === "authentication_failed") { + return { state: "auth-failed", message: "authentication_failed" }; + } + if (message.type !== "result") continue; + if (!message.is_error) { + return { state: "ready", message: null }; + } + const errors = + "errors" in message && Array.isArray(message.errors) + ? message.errors.filter(Boolean).join(" ") + : ""; + if (isClaudeAuthFailureMessage(errors)) { + return { + state: "auth-failed", + message: errors.trim() || "authentication_failed", + }; + } + return { + state: "runtime-failed", + message: errors.trim() || "Claude startup probe returned an error result.", + }; + } + + return { + state: "runtime-failed", + message: "Claude startup probe completed without a terminal result.", + }; + } catch (error) { + if (isClaudeAuthFailureMessage(error)) { + return { + state: "auth-failed", + message: error instanceof Error ? error.message : String(error), + }; + } + return { + state: "runtime-failed", + message: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + try { + stream.close(); + } catch { + // ignore best-effort cleanup + } + } +} + +async function main(): Promise { + const pty = await import("node-pty"); + const claude = await import("@anthropic-ai/claude-agent-sdk"); + const claudeExecutable = resolveClaudeCodeExecutable(); + const packagedPackageJson = typeof process.resourcesPath === "string" && process.resourcesPath.length > 0 + ? path.join(process.resourcesPath, "app.asar", "package.json") + : ""; + const runtimeRequire = createRequire(fs.existsSync(packagedPackageJson) ? packagedPackageJson : __filename); + const codexProvider = runtimeRequire("ai-sdk-provider-codex-cli") as Record; + const codexFactory = (codexProvider.createCodexCli ?? codexProvider.createCodexCLI) as unknown; + const cwd = process.cwd(); + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot: cwd, + workspaceRoot: cwd, + }); + const ptyProbe = await probePty(); + const claudeStartup = await probeClaudeStartup(claudeExecutable.path); + + const proxyProbe = await execFileAsync(launch.command, [...launch.cmdArgs, "--probe"], { + cwd, + env: { + ...process.env, + ...launch.env, + }, + }); + + const proxyProbeStdout = proxyProbe.stdout.trim(); + let proxyProbeResult: unknown = null; + try { + proxyProbeResult = proxyProbeStdout ? JSON.parse(proxyProbeStdout) : null; + } catch { + proxyProbeResult = proxyProbeStdout; + } + + process.stdout.write(JSON.stringify({ + ok: true, + nodePty: typeof pty.spawn, + claudeQuery: typeof claude.query, + claudeExecutablePath: claudeExecutable.path, + claudeExecutableSource: claudeExecutable.source, + claudeStartup, + codexFactory: typeof codexFactory, + ptyProbe, + launchMode: launch.mode, + launchCommand: launch.command, + launchEntryPath: launch.entryPath, + launchSocketPath: launch.socketPath, + proxyProbe: proxyProbeResult, + })); +} + +void main().catch((error) => { + process.stderr.write(error instanceof Error ? error.stack ?? error.message : String(error)); + process.exit(1); +}); diff --git a/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts b/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts new file mode 100644 index 00000000..d5a64084 --- /dev/null +++ b/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; + +describe("resolveClaudeCodeExecutable", () => { + it("prefers the explicit env override", () => { + expect( + resolveClaudeCodeExecutable({ + env: { + CLAUDE_CODE_EXECUTABLE_PATH: "/custom/bin/claude", + PATH: "/usr/bin:/bin", + }, + }), + ).toEqual({ + path: "/custom/bin/claude", + source: "env", + }); + }); + + it("uses the detected Claude auth path before falling back to PATH lookup", () => { + expect( + resolveClaudeCodeExecutable({ + auth: [ + { + type: "cli-subscription", + cli: "claude", + path: "/opt/homebrew/bin/claude", + authenticated: true, + verified: true, + }, + ], + env: { + PATH: "/usr/bin:/bin", + }, + }), + ).toEqual({ + path: "/opt/homebrew/bin/claude", + source: "auth", + }); + }); +}); diff --git a/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts b/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts new file mode 100644 index 00000000..d45d89c0 --- /dev/null +++ b/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { DetectedAuth } from "./authDetector"; + +export type ClaudeCodeExecutableResolution = { + path: string; + source: "env" | "auth" | "path" | "common-dir" | "fallback-command"; +}; + +const HOME_DIR = os.homedir(); +const COMMON_BIN_DIRS = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + `${HOME_DIR}/.local/bin`, + `${HOME_DIR}/.nvm/current/bin`, +].filter(Boolean); + +function isExecutableFile(candidatePath: string): boolean { + try { + const stat = fs.statSync(candidatePath); + return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0); + } catch { + return false; + } +} + +function resolveFromPathEntries(command: string, pathValue: string | undefined): string | null { + if (!pathValue) return null; + for (const entry of pathValue.split(path.delimiter)) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const candidatePath = path.join(trimmed, command); + if (isExecutableFile(candidatePath)) { + return candidatePath; + } + } + return null; +} + +function findClaudeAuthPath(auth?: DetectedAuth[]): string | null { + const entry = auth?.find((item) => item.type === "cli-subscription" && item.cli === "claude"); + if (!entry) return null; + if (entry.type !== "cli-subscription") return null; + return entry.path.trim().length > 0 ? entry.path : null; +} + +export function resolveClaudeCodeExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): ClaudeCodeExecutableResolution { + const env = args?.env ?? process.env; + const envPath = env.CLAUDE_CODE_EXECUTABLE_PATH?.trim(); + if (envPath) { + return { path: envPath, source: "env" }; + } + + const authPath = findClaudeAuthPath(args?.auth); + if (authPath) { + return { path: authPath, source: "auth" }; + } + + const pathResolved = resolveFromPathEntries("claude", env.PATH); + if (pathResolved) { + return { path: pathResolved, source: "path" }; + } + + for (const binDir of COMMON_BIN_DIRS) { + const candidatePath = path.join(binDir, "claude"); + if (isExecutableFile(candidatePath)) { + return { path: candidatePath, source: "common-dir" }; + } + } + + return { path: "claude", source: "fallback-command" }; +} diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts index 78406d1e..c50cb8cf 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts @@ -11,6 +11,13 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: (...args: unknown[]) => mockState.query(...args), })); +vi.mock("./claudeCodeExecutable", () => ({ + resolveClaudeCodeExecutable: () => ({ + path: "/mock/bin/claude", + source: "auth", + }), +})); + vi.mock("./providerRuntimeHealth", () => ({ reportProviderRuntimeReady: (...args: unknown[]) => mockState.reportProviderRuntimeReady(...args), reportProviderRuntimeAuthFailure: (...args: unknown[]) => mockState.reportProviderRuntimeAuthFailure(...args), @@ -64,6 +71,14 @@ describe("claudeRuntimeProbe", () => { await probeClaudeRuntimeHealth({ projectRoot: "/tmp/project", force: true }); expect(query.close).toHaveBeenCalledTimes(1); + expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({ + options: expect.objectContaining({ + pathToClaudeCodeExecutable: "/mock/bin/claude", + mcpServers: expect.objectContaining({ + ade: expect.any(Object), + }), + }), + })); expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1); expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index f5c73c3b..9a83ec20 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -6,6 +6,9 @@ import { reportProviderRuntimeFailure, reportProviderRuntimeReady, } from "./providerRuntimeHealth"; +import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; +import { normalizeCliMcpServers } from "./providerResolver"; +import { resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "../runtime/adeMcpLaunch"; const PROBE_TIMEOUT_MS = 20_000; const PROBE_CACHE_TTL_MS = 30_000; @@ -84,6 +87,23 @@ function cacheResult(projectRoot: string, result: ClaudeRuntimeProbeResult): Cla return result; } +function resolveProbeMcpServers(projectRoot: string): Record | undefined { + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, + workspaceRoot: projectRoot, + runtimeRoot: resolveRepoRuntimeRoot(), + defaultRole: "external", + }); + + return normalizeCliMcpServers("claude", { + ade: { + command: launch.command, + args: launch.cmdArgs, + env: launch.env, + }, + }); +} + function publishResult(result: ClaudeRuntimeProbeResult): void { if (result.state === "ready") { reportProviderRuntimeReady("claude"); @@ -122,12 +142,15 @@ export async function probeClaudeRuntimeHealth(args: { const probe = (async (): Promise => { const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), PROBE_TIMEOUT_MS); + const claudeExecutable = resolveClaudeCodeExecutable(); const stream = claudeQuery({ prompt: "System initialization check. Respond with only the word READY.", options: { cwd: projectRoot, permissionMode: "plan", tools: [], + pathToClaudeCodeExecutable: claudeExecutable.path, + mcpServers: resolveProbeMcpServers(projectRoot), abortController, }, }); @@ -172,6 +195,7 @@ export async function probeClaudeRuntimeHealth(args: { projectRoot, state: result.state, message: result.message, + claudeExecutablePath: resolveClaudeCodeExecutable().path, }); } } finally { diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index a1d595c6..407813e1 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -124,4 +124,84 @@ describe("buildProviderConnections", () => { expect(result.codex.blocker).toContain("Codex CLI reports no active login"); expect(result.codex.blocker).toContain("codex login"); }); + + it("downgrades Claude runtime availability when runtime health reports auth-failed", async () => { + mockState.readClaudeCredentials.mockResolvedValue({ + accessToken: "token", + source: "claude-credentials-file", + }); + mockState.getProviderRuntimeHealth.mockImplementation((provider) => { + if (provider === "claude") { + return { + provider: "claude", + state: "auth-failed", + message: "Claude runtime reported that login is still required.", + checkedAt: "2026-03-17T19:00:00.000Z", + }; + } + return null; + }); + + const result = await buildProviderConnections([ + { + cli: "claude", + installed: true, + path: "/Users/arul/.local/bin/claude", + authenticated: true, + verified: true, + }, + { + cli: "codex", + installed: false, + path: null, + authenticated: false, + verified: false, + }, + ]); + + expect(result.claude.authAvailable).toBe(true); + expect(result.claude.runtimeDetected).toBe(true); + expect(result.claude.runtimeAvailable).toBe(false); + expect(result.claude.blocker).toBe("Claude runtime reported that login is still required."); + }); + + it("downgrades Claude runtime availability when runtime health reports a launch failure", async () => { + mockState.readClaudeCredentials.mockResolvedValue({ + accessToken: "token", + source: "claude-credentials-file", + }); + mockState.getProviderRuntimeHealth.mockImplementation((provider) => { + if (provider === "claude") { + return { + provider: "claude", + state: "runtime-failed", + message: "ADE could not launch the Claude runtime from this packaged app session.", + checkedAt: "2026-03-17T19:00:00.000Z", + }; + } + return null; + }); + + const result = await buildProviderConnections([ + { + cli: "claude", + installed: true, + path: "/Users/arul/.local/bin/claude", + authenticated: true, + verified: true, + }, + { + cli: "codex", + installed: false, + path: null, + authenticated: false, + verified: false, + }, + ]); + + expect(result.claude.authAvailable).toBe(true); + expect(result.claude.runtimeDetected).toBe(true); + expect(result.claude.runtimeAvailable).toBe(false); + expect(result.claude.blocker).toBe("ADE could not launch the Claude runtime from this packaged app session."); + }); }); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index bd90edb4..a0008bd9 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -83,16 +83,18 @@ export async function buildProviderConnections( } // Apply runtime health overrides. - // Only an explicit auth failure should downgrade status. Transient probe - // failures (process abort, timeout) should not block a user with valid creds. + // If ADE cannot launch the actual provider runtime from this app session, + // surface that as not runtime-available even when auth artifacts exist. function applyRuntimeHealth( status: AiProviderConnectionStatus, health: ReturnType, ): void { - if (health?.state === "auth-failed") { + if (health?.state === "auth-failed" || health?.state === "runtime-failed") { status.runtimeAvailable = false; status.blocker = health.message - ?? `${status.provider} runtime was detected, but ADE chat reported that login is still required.`; + ?? (health.state === "auth-failed" + ? `${status.provider} runtime was detected, but ADE chat reported that login is still required.` + : `${status.provider} runtime was detected, but ADE could not launch it from this app session.`); } else if (health?.state === "ready") { status.runtimeAvailable = true; status.authAvailable = true; diff --git a/apps/desktop/src/main/services/ai/providerResolver.test.ts b/apps/desktop/src/main/services/ai/providerResolver.test.ts index bc956099..b23ad567 100644 --- a/apps/desktop/src/main/services/ai/providerResolver.test.ts +++ b/apps/desktop/src/main/services/ai/providerResolver.test.ts @@ -6,13 +6,29 @@ const { createCodexCliMock } = vi.hoisted(() => ({ createCodexCliMock: vi.fn(), })); +const { createClaudeCodeMock } = vi.hoisted(() => ({ + createClaudeCodeMock: vi.fn(), +})); + vi.mock("ai-sdk-provider-codex-cli", () => ({ createCodexCli: createCodexCliMock, })); +vi.mock("ai-sdk-provider-claude-code", () => ({ + createClaudeCode: createClaudeCodeMock, +})); + +vi.mock("./claudeCodeExecutable", () => ({ + resolveClaudeCodeExecutable: () => ({ + path: "/mock/bin/claude", + source: "auth", + }), +})); + describe("providerResolver codex CLI", () => { beforeEach(() => { createCodexCliMock.mockReset(); + createClaudeCodeMock.mockReset(); }); it("resolves Codex CLI models through the community provider with MCP settings", async () => { @@ -79,6 +95,59 @@ describe("providerResolver codex CLI", () => { expect(createCodexCliMock).not.toHaveBeenCalled(); }); + it("resolves Claude CLI models through the provider with an explicit executable path", async () => { + const sdkModel = { modelId: "mock-claude-model" } as any; + const providerInstance = vi.fn(() => sdkModel); + createClaudeCodeMock.mockReturnValue(providerInstance); + + const auth: DetectedAuth[] = [ + { + type: "cli-subscription", + cli: "claude", + path: "/opt/homebrew/bin/claude", + authenticated: true, + verified: true, + }, + ]; + + const resolved = await resolveModel("anthropic/claude-haiku-4-5", auth, { + middleware: false, + cwd: "/tmp/worktree", + cli: { + mcpServers: { + ade: { + command: "node", + args: ["/tmp/mcp-server.js"], + env: { + ADE_RUN_ID: "run-1", + }, + }, + }, + }, + }); + + expect(createClaudeCodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + defaultSettings: expect.objectContaining({ + cwd: "/tmp/worktree", + pathToClaudeCodeExecutable: "/mock/bin/claude", + mcpServers: { + ade: { + type: "stdio", + command: "node", + args: ["/tmp/mcp-server.js"], + env: { + ADE_RUN_ID: "run-1", + }, + }, + }, + }), + }), + ); + expect(providerInstance).toHaveBeenCalledWith("haiku"); + expect(resolved).toBe(sdkModel); + }); + it("normalizes ADE MCP server config for both Claude and Codex CLI providers", () => { const raw = { ade: { diff --git a/apps/desktop/src/main/services/ai/providerResolver.ts b/apps/desktop/src/main/services/ai/providerResolver.ts index 2be5fede..dc2319a9 100644 --- a/apps/desktop/src/main/services/ai/providerResolver.ts +++ b/apps/desktop/src/main/services/ai/providerResolver.ts @@ -9,6 +9,7 @@ import { type ModelDescriptor, } from "../../../shared/modelRegistry"; import type { DetectedAuth } from "./authDetector"; +import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; import { wrapWithMiddleware, type WrapMiddlewareOpts } from "./middleware"; import { resolveViaAdeProviderRegistry } from "./adeProviderRegistry"; export { buildProviderOptions } from "./providerOptions"; @@ -221,6 +222,7 @@ export function normalizeCliMcpServers( function buildCliDefaultSettings( provider: "claude" | "codex", opts?: ResolveModelOpts, + auth?: DetectedAuth[], ): Record { const settings: Record = {}; const cwd = opts?.cwd?.trim() || process.cwd(); @@ -238,6 +240,9 @@ function buildCliDefaultSettings( if (provider === "claude" && settings.systemPrompt == null) { settings.systemPrompt = { type: "preset", preset: "claude_code" }; } + if (provider === "claude" && settings.pathToClaudeCodeExecutable == null) { + settings.pathToClaudeCodeExecutable = resolveClaudeCodeExecutable({ auth }).path; + } return settings; } @@ -282,7 +287,7 @@ async function resolveCliWrapped( } const createClaudeCode = await loadClaudeCodeProvider(); const provider = createClaudeCode({ - defaultSettings: buildCliDefaultSettings("claude", opts), + defaultSettings: buildCliDefaultSettings("claude", opts, auth), }); return provider(descriptor.sdkModelId) as LanguageModel; } diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f41572ac..c163bd9a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -37,6 +37,7 @@ import type { createFileService } from "../files/fileService"; import type { createProcessService } from "../processes/processService"; import { runGit } from "../git/git"; import { CLAUDE_RUNTIME_AUTH_ERROR, isClaudeRuntimeAuthError } from "../ai/claudeRuntimeProbe"; +import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { nowIso, fileSizeOrZero } from "../shared/utils"; import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import { @@ -106,7 +107,7 @@ import type { createFlowPolicyService } from "../cto/flowPolicyService"; import type { createLinearDispatcherService } from "../cto/linearDispatcherService"; import type { createPrService } from "../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter"; +import { resolveAdeMcpServerLaunch, resolveUnifiedRuntimeRoot } from "../orchestrator/unifiedOrchestratorAdapter"; import type { createMissionService } from "../missions/missionService"; import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; @@ -990,20 +991,7 @@ function isLightweightSession(session: Pick) let _mcpRuntimeRootCache: string | null = null; function resolveMcpRuntimeRoot(): string { if (_mcpRuntimeRootCache !== null) return _mcpRuntimeRootCache; - const startPoints = [process.cwd(), __dirname]; - for (const start of startPoints) { - let dir = path.resolve(start); - for (let i = 0; i < 12; i += 1) { - if (fs.existsSync(path.join(dir, "apps", "mcp-server", "package.json"))) { - _mcpRuntimeRootCache = dir; - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } - _mcpRuntimeRootCache = process.cwd(); + _mcpRuntimeRootCache = resolveUnifiedRuntimeRoot(); return _mcpRuntimeRootCache; } @@ -1172,6 +1160,29 @@ export function createAgentChatService(args: { }) ?? {}; }; + const summarizeAdeMcpLaunch = (args: { + defaultRole: "agent" | "cto" | "external"; + ownerId?: string | null; + computerUsePolicy?: ComputerUsePolicy | null; + }) => { + const launch = resolveAdeMcpServerLaunch({ + workspaceRoot: projectRoot, + runtimeRoot: resolveMcpRuntimeRoot(), + defaultRole: args.defaultRole, + ownerId: args.ownerId ?? undefined, + computerUsePolicy: normalizeComputerUsePolicy(args.computerUsePolicy, createDefaultComputerUsePolicy()), + }); + return { + mode: launch.mode, + command: launch.command, + entryPath: launch.entryPath, + runtimeRoot: launch.runtimeRoot, + socketPath: launch.socketPath, + packaged: launch.packaged, + resourcesPath: launch.resourcesPath, + }; + }; + const readTranscriptConversationEntries = (managed: ManagedChatSession): string[] => { try { const raw = fs.readFileSync(managed.transcriptPath, "utf8"); @@ -4346,6 +4357,17 @@ export function createAgentChatService(args: { }; const startCodexRuntime = async (managed: ManagedChatSession): Promise => { + logger.info("agent_chat.codex_runtime_start", { + sessionId: managed.session.id, + cwd: managed.laneWorktreePath, + shellPath: process.env.SHELL ?? "", + path: process.env.PATH ?? "", + adeMcpLaunch: summarizeAdeMcpLaunch({ + defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", + ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey), + computerUsePolicy: managed.session.computerUse, + }), + }); const proc = spawn("codex", ["app-server"], { cwd: managed.laneWorktreePath, stdio: ["pipe", "pipe", "pipe"] @@ -4451,7 +4473,38 @@ export function createAgentChatService(args: { if (!text.length) return; logger.warn("agent_chat.codex_stderr", { sessionId: managed.session.id, - line: text + line: text, + cwd: managed.laneWorktreePath, + }); + }); + + proc.on("error", (error) => { + const message = `Codex app-server failed to start: ${error instanceof Error ? error.message : String(error)}`; + logger.warn("agent_chat.codex_spawn_failed", { + sessionId: managed.session.id, + cwd: managed.laneWorktreePath, + path: process.env.PATH ?? "", + shellPath: process.env.SHELL ?? "", + error: error instanceof Error ? error.message : String(error), + }); + + for (const request of pending.values()) { + request.reject(new Error(message)); + } + pending.clear(); + runtime.approvals.clear(); + runtime.suppressExitError = true; + + if (managed.closed || managed.session.status === "ended") return; + + emitChatEvent(managed, { + type: "error", + message, + }); + + void finishSession(managed, "failed", { + exitCode: null, + summary: message, }); }); @@ -4587,6 +4640,7 @@ export function createAgentChatService(args: { ? mapPermissionToClaude(managed.session.permissionMode) : chatConfig.claudePermissionMode; const lightweight = isLightweightSession(managed.session); + const claudeExecutable = resolveClaudeCodeExecutable(); const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, permissionMode: claudePermissionMode as any, @@ -4595,6 +4649,7 @@ export function createAgentChatService(args: { promptSuggestions: true, maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, model: resolveClaudeCliModel(managed.session.model), + pathToClaudeCodeExecutable: claudeExecutable.path, }; if (!lightweight) { opts.systemPrompt = { @@ -4716,6 +4771,7 @@ export function createAgentChatService(args: { sessionId: managed.session.id, resume: !!runtime.sdkSessionId, model: v2Opts.model, + claudeExecutablePath: v2Opts.pathToClaudeCodeExecutable, }); if (runtime.v2WarmupCancelled) return; @@ -4793,6 +4849,12 @@ export function createAgentChatService(args: { logger.warn("agent_chat.claude_v2_prewarm_failed", { sessionId: managed.session.id, error: error instanceof Error ? error.message : String(error), + claudeExecutablePath: runtime.v2Session ? undefined : buildClaudeV2SessionOpts(managed, runtime).pathToClaudeCodeExecutable, + adeMcpLaunch: summarizeAdeMcpLaunch({ + defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", + ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey), + computerUsePolicy: managed.session.computerUse, + }), }); try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts index aba762da..a469e6f9 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts @@ -6,6 +6,7 @@ import { buildClaudeReadOnlyWorkerAllowedTools, buildCodexMcpConfigFlags, createUnifiedOrchestratorAdapter, + resolveAdeMcpServerLaunch, } from "./unifiedOrchestratorAdapter"; describe("buildCodexMcpConfigFlags", () => { @@ -13,6 +14,7 @@ describe("buildCodexMcpConfigFlags", () => { const flags = buildCodexMcpConfigFlags({ workspaceRoot: "/Users/admin/Projects/ADE", runtimeRoot: "/tmp/ade-runtime", + preferBundledProxy: false, missionId: "mission-123", runId: "run-456", stepId: "step-789", @@ -29,6 +31,8 @@ describe("buildCodexMcpConfigFlags", () => { "-c", `'mcp_servers.ade.env.ADE_WORKSPACE_ROOT="/Users/admin/Projects/ADE"'`, "-c", + `'mcp_servers.ade.env.ADE_MCP_SOCKET_PATH="/Users/admin/Projects/ADE/.ade/mcp.sock"'`, + "-c", `'mcp_servers.ade.env.ADE_MISSION_ID="mission-123"'`, "-c", `'mcp_servers.ade.env.ADE_RUN_ID="run-456"'`, @@ -42,6 +46,50 @@ describe("buildCodexMcpConfigFlags", () => { }); }); +describe("resolveAdeMcpServerLaunch", () => { + it("prefers the packaged dist entry when the built MCP server exists", () => { + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mcp-runtime-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mcp-project-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + const builtEntry = path.join(runtimeRoot, "apps", "mcp-server", "dist", "index.cjs"); + + fs.mkdirSync(path.dirname(builtEntry), { recursive: true }); + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.writeFileSync(builtEntry, "module.exports = {};\n", "utf8"); + + const launch = resolveAdeMcpServerLaunch({ + projectRoot, + workspaceRoot, + runtimeRoot, + preferBundledProxy: false, + missionId: "mission-123", + runId: "run-456", + stepId: "step-789", + attemptId: "attempt-000", + defaultRole: "external", + }); + + expect(launch.command).toBe("node"); + expect(launch.cmdArgs).toEqual([ + builtEntry, + "--project-root", + path.resolve(projectRoot), + "--workspace-root", + path.resolve(workspaceRoot), + ]); + expect(launch.env).toMatchObject({ + ADE_PROJECT_ROOT: path.resolve(projectRoot), + ADE_WORKSPACE_ROOT: path.resolve(workspaceRoot), + ADE_MCP_SOCKET_PATH: path.join(path.resolve(projectRoot), ".ade", "mcp.sock"), + ADE_MISSION_ID: "mission-123", + ADE_RUN_ID: "run-456", + ADE_STEP_ID: "step-789", + ADE_ATTEMPT_ID: "attempt-000", + ADE_DEFAULT_ROLE: "external", + }); + }); +}); + describe("buildClaudeReadOnlyWorkerAllowedTools", () => { it("includes only safe native read tools plus ADE reporting/status tools and memory tools", () => { expect(buildClaudeReadOnlyWorkerAllowedTools()).toEqual([ @@ -374,14 +422,25 @@ describe("createUnifiedOrchestratorAdapter", () => { expect(result.status).toBe("accepted"); const configPath = path.join(projectRoot, ".ade", "cache", "orchestrator", "mcp-configs", "worker-attempt-1.json"); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - expect(config.mcpServers.ade.args).toEqual([ - "tsx", - path.join(projectRoot, "runtime", "apps", "mcp-server", "src", "index.ts"), + expect(config.mcpServers.ade.args.slice(-4)).toEqual([ "--project-root", projectRoot, "--workspace-root", laneWorktreePath, ]); + if (config.mcpServers.ade.command === process.execPath) { + expect(config.mcpServers.ade.args[0]).toMatch(/adeMcpProxy\.cjs$/); + expect(config.mcpServers.ade.env.ELECTRON_RUN_AS_NODE).toBe("1"); + } else { + expect(config.mcpServers.ade.args).toEqual([ + "tsx", + path.join(projectRoot, "runtime", "apps", "mcp-server", "src", "index.ts"), + "--project-root", + projectRoot, + "--workspace-root", + laneWorktreePath, + ]); + } expect(config.mcpServers.ade.env).toMatchObject({ ADE_PROJECT_ROOT: projectRoot, ADE_WORKSPACE_ROOT: laneWorktreePath, diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts index ebf0b18a..a74f5b0b 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -25,6 +25,7 @@ import { normalizeMissionPermissions, providerPermissionsToLegacyConfig, } from "./permissionMapping"; +import { resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "../runtime/adeMcpLaunch"; /** * Build environment variable assignments for worker identity. @@ -64,54 +65,33 @@ export function resolveAdeMcpServerLaunch(args: { defaultRole?: string; ownerId?: string; computerUsePolicy?: ComputerUsePolicy | null; + bundledProxyPath?: string; + preferBundledProxy?: boolean; }): { + mode: "bundled_proxy" | "headless_built" | "headless_source"; command: string; cmdArgs: string[]; env: Record; + entryPath: string; + runtimeRoot: string | null; + socketPath: string; + packaged: boolean; + resourcesPath: string | null; } { - const canonicalProjectRoot = typeof args.projectRoot === "string" && args.projectRoot.trim().length > 0 - ? path.resolve(args.projectRoot) - : path.resolve(args.workspaceRoot); - const workspaceRoot = path.resolve(args.workspaceRoot); - const mcpServerDir = path.resolve(args.runtimeRoot, "apps", "mcp-server"); - const builtEntry = path.join(mcpServerDir, "dist", "index.cjs"); - const srcEntry = path.join(mcpServerDir, "src", "index.ts"); - - let command: string; - let cmdArgs: string[]; - - if (fs.existsSync(builtEntry)) { - command = "node"; - cmdArgs = [builtEntry, "--project-root", canonicalProjectRoot, "--workspace-root", workspaceRoot]; - } else { - command = "npx"; - cmdArgs = ["tsx", srcEntry, "--project-root", canonicalProjectRoot, "--workspace-root", workspaceRoot]; - } - - return { - command, - cmdArgs, - env: { - ADE_PROJECT_ROOT: canonicalProjectRoot, - ADE_WORKSPACE_ROOT: workspaceRoot, - ADE_MISSION_ID: args.missionId ?? "", - ADE_RUN_ID: args.runId ?? "", - ADE_STEP_ID: args.stepId ?? "", - ADE_ATTEMPT_ID: args.attemptId ?? "", - ADE_DEFAULT_ROLE: args.defaultRole ?? "agent", - ADE_OWNER_ID: args.ownerId ?? "", - ADE_COMPUTER_USE_MODE: args.computerUsePolicy?.mode ?? "", - ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK: - typeof args.computerUsePolicy?.allowLocalFallback === "boolean" - ? (args.computerUsePolicy.allowLocalFallback ? "1" : "0") - : "", - ADE_COMPUTER_USE_RETAIN_ARTIFACTS: - typeof args.computerUsePolicy?.retainArtifacts === "boolean" - ? (args.computerUsePolicy.retainArtifacts ? "1" : "0") - : "", - ADE_COMPUTER_USE_PREFERRED_BACKEND: args.computerUsePolicy?.preferredBackend ?? "", - } - }; + return resolveDesktopAdeMcpLaunch({ + projectRoot: args.projectRoot, + workspaceRoot: args.workspaceRoot, + runtimeRoot: args.runtimeRoot, + missionId: args.missionId, + runId: args.runId, + stepId: args.stepId, + attemptId: args.attemptId, + defaultRole: args.defaultRole, + ownerId: args.ownerId, + computerUsePolicy: args.computerUsePolicy, + bundledProxyPath: args.bundledProxyPath, + preferBundledProxy: args.preferBundledProxy, + }); } export function getUnifiedUnsupportedModelReason(modelRef: string): string | null { @@ -242,19 +222,7 @@ function writeWorkerPromptFile(args: { * Walks up from cwd looking for package.json with the monorepo marker. */ export function resolveUnifiedRuntimeRoot(): string { - // The adapter runs inside the desktop Electron process. - // The project root is the monorepo root (parent of apps/). - // Walk up from __dirname to find the root containing apps/mcp-server. - let dir = process.cwd(); - for (let i = 0; i < 10; i++) { - if (fs.existsSync(path.join(dir, "apps", "mcp-server", "package.json"))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return process.cwd(); + return resolveRepoRuntimeRoot(); } /** @@ -272,6 +240,8 @@ export function buildCodexMcpConfigFlags(args: { attemptId?: string; ownerId?: string | null; defaultRole?: string; + bundledProxyPath?: string; + preferBundledProxy?: boolean; }): string[] { const launch = resolveAdeMcpServerLaunch({ projectRoot: args.projectRoot, @@ -283,6 +253,8 @@ export function buildCodexMcpConfigFlags(args: { attemptId: args.attemptId, defaultRole: args.defaultRole ?? "agent", ownerId: args.ownerId ?? undefined, + bundledProxyPath: args.bundledProxyPath, + preferBundledProxy: args.preferBundledProxy, }); // Codex -c flag parses values as TOML @@ -290,17 +262,13 @@ export function buildCodexMcpConfigFlags(args: { const flags: string[] = [ "-c", shellEscapeArg(`mcp_servers.ade.command="${launch.command}"`), "-c", shellEscapeArg(`mcp_servers.ade.args=${argsToml}`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_PROJECT_ROOT="${launch.env.ADE_PROJECT_ROOT}"`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_WORKSPACE_ROOT="${launch.env.ADE_WORKSPACE_ROOT}"`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_MISSION_ID="${launch.env.ADE_MISSION_ID}"`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_RUN_ID="${launch.env.ADE_RUN_ID}"`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_STEP_ID="${launch.env.ADE_STEP_ID}"`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_ATTEMPT_ID="${launch.env.ADE_ATTEMPT_ID}"`), - "-c", shellEscapeArg(`mcp_servers.ade.env.ADE_DEFAULT_ROLE="${launch.env.ADE_DEFAULT_ROLE}"`) + ...Object.entries(launch.env) + .filter(([, value]) => value.trim().length > 0) + .flatMap(([key, value]) => [ + "-c", + shellEscapeArg(`mcp_servers.ade.env.${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`), + ]), ]; - if (launch.env.ADE_OWNER_ID?.trim()) { - flags.push("-c", shellEscapeArg(`mcp_servers.ade.env.ADE_OWNER_ID="${launch.env.ADE_OWNER_ID}"`)); - } return flags; } diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index a7d549d1..d203e27a 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -702,6 +702,50 @@ export function createProcessService({ child.stderr.on("data", (chunk: string) => onChunk("stderr", chunk)); }; + const handleProcessStartFailure = (args: { + entry: ManagedProcessEntry; + laneId: string; + definition: ProcessDefinition; + runId: string; + startedAt: string; + endedAt: string; + logPath: string; + cwd: string; + error: unknown; + }) => { + const { entry, laneId, definition, runId, startedAt, endedAt, logPath, cwd, error } = args; + const message = error instanceof Error ? error.message : String(error); + + entry.child = null; + entry.processGroupId = null; + entry.runId = null; + entry.stopIntent = null; + entry.runtime.pid = null; + entry.runtime.status = "crashed"; + entry.runtime.readiness = "unknown"; + entry.runtime.startedAt = startedAt; + entry.runtime.endedAt = endedAt; + entry.runtime.lastEndedAt = endedAt; + entry.runtime.exitCode = null; + entry.runtime.lastExitCode = null; + entry.runtime.logPath = logPath; + emitRuntime(entry); + + upsertRunStart(runId, laneId, definition.id, startedAt, logPath); + upsertRunEnd(runId, endedAt, null, "crashed"); + + logger.warn("process.start_failed", { + laneId, + processId: definition.id, + cwd, + command: definition.command, + envPath: process.env.PATH ?? "", + envShell: process.env.SHELL ?? "", + resourcesPath: process.resourcesPath ?? "", + error: message, + }); + }; + const startByDefinition = async ( laneId: string, definition: ProcessDefinition, @@ -739,11 +783,28 @@ export function createProcessService({ stdio: ["ignore", "pipe", "pipe"] }); } catch (err) { + const endedAt = nowIso(); + try { + logStream.write(`\n[process start failure] ${String(err)}\n`); + } catch { + // ignore + } try { logStream.end(); } catch { // ignore } + handleProcessStartFailure({ + entry, + laneId, + definition, + runId, + startedAt, + endedAt, + logPath, + cwd, + error: err, + }); throw err; } @@ -781,7 +842,15 @@ export function createProcessService({ handleProcessExit(entry, definition.id, code ?? null); }); - logger.info("process.start", { laneId, processId: definition.id, cwd, command: definition.command, runId }); + logger.info("process.start", { + laneId, + processId: definition.id, + cwd, + command: definition.command, + runId, + envPath: process.env.PATH ?? "", + envShell: process.env.SHELL ?? "", + }); return { ...entry.runtime }; }; diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts new file mode 100644 index 00000000..42102485 --- /dev/null +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPtyService } from "./ptyService"; + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createSessionServiceMock() { + return { + create: vi.fn(), + end: vi.fn(), + get: vi.fn().mockReturnValue(null), + setHeadShaStart: vi.fn(), + setHeadShaEnd: vi.fn(), + setLastOutputPreview: vi.fn(), + setSummary: vi.fn(), + setResumeCommand: vi.fn(), + updateMeta: vi.fn(), + readTranscriptTail: vi.fn().mockResolvedValue(""), + }; +} + +function createLaneServiceMock(worktreePath: string) { + return { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ worktreePath }), + }; +} + +function createServiceFixture() { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pty-")); + const worktreePath = path.join(projectRoot, "worktree"); + const transcriptsDir = path.join(projectRoot, "transcripts"); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.mkdirSync(transcriptsDir, { recursive: true }); + + const logger = createLogger(); + const sessionService = createSessionServiceMock(); + const laneService = createLaneServiceMock(worktreePath); + const broadcastData = vi.fn(); + const broadcastExit = vi.fn(); + + const service = createPtyService({ + projectRoot, + transcriptsDir, + laneService: laneService as any, + sessionService: sessionService as any, + logger, + broadcastData, + broadcastExit, + loadPty: () => { + throw new Error("loadPty should be overridden in each test"); + }, + }); + + return { + projectRoot, + worktreePath, + transcriptsDir, + logger, + sessionService, + laneService, + broadcastData, + broadcastExit, + service, + }; +} + +function createFakePty() { + return { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; +} + +beforeEach(() => { + vi.stubEnv("SHELL", ""); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("createPtyService", () => { + it("ends the session when node-pty fails to load", async () => { + const fixture = createServiceFixture(); + const service = createPtyService({ + projectRoot: fixture.projectRoot, + transcriptsDir: fixture.transcriptsDir, + laneService: fixture.laneService as any, + sessionService: fixture.sessionService as any, + logger: fixture.logger, + broadcastData: fixture.broadcastData, + broadcastExit: fixture.broadcastExit, + loadPty: () => { + throw new Error("node-pty missing"); + }, + }); + + await expect(service.create({ + laneId: "lane-1", + cols: 80, + rows: 24, + title: "Shell", + tracked: false, + })).rejects.toThrow("node-pty missing"); + + expect(fixture.sessionService.create).toHaveBeenCalledTimes(1); + expect(fixture.sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + status: "failed", + exitCode: null, + })); + expect(fixture.broadcastExit).toHaveBeenCalledWith(expect.objectContaining({ + exitCode: null, + })); + expect(fixture.logger.error).toHaveBeenCalledWith( + "pty.spawn_failed", + expect.objectContaining({ + err: expect.stringContaining("node-pty missing"), + }), + ); + }); + + it("retries shell candidates and fails cleanly when every spawn attempt throws", async () => { + const fixture = createServiceFixture(); + const spawn = vi.fn((shell: string) => { + throw new Error(`spawn failed for ${shell}`); + }); + const service = createPtyService({ + projectRoot: fixture.projectRoot, + transcriptsDir: fixture.transcriptsDir, + laneService: fixture.laneService as any, + sessionService: fixture.sessionService as any, + logger: fixture.logger, + broadcastData: fixture.broadcastData, + broadcastExit: fixture.broadcastExit, + loadPty: () => ({ spawn } as any), + }); + + await expect(service.create({ + laneId: "lane-1", + cols: 80, + rows: 24, + title: "Shell", + tracked: false, + })).rejects.toThrow("spawn failed for /bin/sh"); + + expect(spawn).toHaveBeenCalledTimes(3); + expect(spawn.mock.calls.map(([shell]) => shell)).toEqual([ + "/bin/zsh", + "/bin/bash", + "/bin/sh", + ]); + expect(fixture.logger.warn).toHaveBeenNthCalledWith( + 1, + "pty.spawn_retry", + expect.objectContaining({ shell: "/bin/zsh" }), + ); + expect(fixture.logger.warn).toHaveBeenNthCalledWith( + 2, + "pty.spawn_retry", + expect.objectContaining({ shell: "/bin/bash" }), + ); + expect(fixture.logger.warn).toHaveBeenNthCalledWith( + 3, + "pty.spawn_retry", + expect.objectContaining({ shell: "/bin/sh" }), + ); + expect(fixture.logger.error).toHaveBeenCalledWith( + "pty.spawn_failed", + expect.objectContaining({ + err: expect.stringContaining("spawn failed for /bin/sh"), + }), + ); + expect(fixture.sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + status: "failed", + exitCode: null, + })); + expect(fixture.broadcastExit).toHaveBeenCalledWith(expect.objectContaining({ + exitCode: null, + })); + }); + + it("logs startup command write failures without tearing down the session", async () => { + const fixture = createServiceFixture(); + const fakePty = createFakePty(); + fakePty.write.mockImplementation(() => { + throw new Error("startup write failed"); + }); + const spawn = vi.fn().mockReturnValue(fakePty); + const service = createPtyService({ + projectRoot: fixture.projectRoot, + transcriptsDir: fixture.transcriptsDir, + laneService: fixture.laneService as any, + sessionService: fixture.sessionService as any, + logger: fixture.logger, + broadcastData: fixture.broadcastData, + broadcastExit: fixture.broadcastExit, + loadPty: () => ({ spawn } as any), + }); + + const result = await service.create({ + laneId: "lane-1", + cols: 80, + rows: 24, + title: "Shell", + tracked: false, + startupCommand: "echo hello", + }); + + expect(result).toEqual(expect.objectContaining({ + ptyId: expect.any(String), + sessionId: expect.any(String), + })); + expect(fakePty.write).toHaveBeenCalledWith("echo hello\r"); + expect(fixture.logger.warn).toHaveBeenCalledWith( + "pty.startup_command_failed", + expect.objectContaining({ + err: expect.stringContaining("startup write failed"), + }), + ); + expect(fixture.sessionService.end).not.toHaveBeenCalled(); + expect(fixture.broadcastExit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index dfe1958b..656b4292 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -574,7 +574,18 @@ export function createPtyService({ break; } catch (err) { lastErr = err; - logger.warn("pty.spawn_retry", { ptyId, sessionId, shell: shell.file, err: String(err) }); + logger.warn("pty.spawn_retry", { + ptyId, + sessionId, + shell: shell.file, + cwd, + toolType: toolTypeHint, + startupCommandPresent: Boolean(startupCommand), + envShell: process.env.SHELL ?? "", + envPath: process.env.PATH ?? "", + resourcesPath: process.resourcesPath ?? "", + err: String(err), + }); } } if (!created) { @@ -582,7 +593,19 @@ export function createPtyService({ } pty = created; } catch (err) { - logger.error("pty.spawn_failed", { ptyId, sessionId, err: String(err) }); + logger.error("pty.spawn_failed", { + ptyId, + sessionId, + cwd, + toolType: toolTypeHint, + startupCommandPresent: Boolean(startupCommand), + selectedShell: selectedShell?.file ?? null, + shellCandidates: shellCandidates.map((shell) => shell.file), + envShell: process.env.SHELL ?? "", + envPath: process.env.PATH ?? "", + resourcesPath: process.resourcesPath ?? "", + err: String(err), + }); for (const cleanupPath of enrichedLaunch.cleanupPaths) { try { fs.unlinkSync(cleanupPath); @@ -708,7 +731,15 @@ export function createPtyService({ setRuntimeState(sessionId, "running"); scheduleIdleTransition(sessionId); } catch (err) { - logger.warn("pty.startup_command_failed", { ptyId, sessionId, err: String(err) }); + logger.warn("pty.startup_command_failed", { + ptyId, + sessionId, + cwd, + toolType: toolTypeHint, + envShell: process.env.SHELL ?? "", + envPath: process.env.PATH ?? "", + err: String(err), + }); } } diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts new file mode 100644 index 00000000..8b900a98 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveDesktopAdeMcpLaunch } from "./adeMcpLaunch"; + +const originalResourcesPath = process.resourcesPath; + +afterEach(() => { + Object.defineProperty(process, "resourcesPath", { + configurable: true, + value: originalResourcesPath, + }); +}); + +describe("resolveDesktopAdeMcpLaunch", () => { + it("prefers the bundled desktop MCP proxy when it is available", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); + + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); + fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); + + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, + workspaceRoot, + bundledProxyPath: proxyEntry, + }); + + expect(launch.mode).toBe("bundled_proxy"); + expect(launch.command).toBe(process.execPath); + expect(launch.cmdArgs).toEqual([ + path.resolve(proxyEntry), + "--project-root", + path.resolve(projectRoot), + "--workspace-root", + path.resolve(workspaceRoot), + ]); + expect(launch.env).toMatchObject({ + ADE_PROJECT_ROOT: path.resolve(projectRoot), + ADE_WORKSPACE_ROOT: path.resolve(workspaceRoot), + ADE_MCP_SOCKET_PATH: path.join(path.resolve(projectRoot), ".ade", "mcp.sock"), + ELECTRON_RUN_AS_NODE: "1", + }); + }); + + it("falls back to the built headless MCP entry when bundled proxy launch is disabled", () => { + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + const builtEntry = path.join(runtimeRoot, "apps", "mcp-server", "dist", "index.cjs"); + + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(builtEntry), { recursive: true }); + fs.writeFileSync(builtEntry, "module.exports = {};\n", "utf8"); + + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, + workspaceRoot, + runtimeRoot, + preferBundledProxy: false, + missionId: "mission-123", + runId: "run-456", + }); + + expect(launch.mode).toBe("headless_built"); + expect(launch.command).toBe("node"); + expect(launch.cmdArgs).toEqual([ + builtEntry, + "--project-root", + path.resolve(projectRoot), + "--workspace-root", + path.resolve(workspaceRoot), + ]); + expect(launch.env).toMatchObject({ + ADE_PROJECT_ROOT: path.resolve(projectRoot), + ADE_WORKSPACE_ROOT: path.resolve(workspaceRoot), + ADE_MISSION_ID: "mission-123", + ADE_RUN_ID: "run-456", + ADE_MCP_SOCKET_PATH: path.join(path.resolve(projectRoot), ".ade", "mcp.sock"), + }); + }); + + it("prefers the unpacked packaged proxy path over the asar path", () => { + const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-resources-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + const packagedProxy = path.join(resourcesPath, "app.asar.unpacked", "dist", "main", "adeMcpProxy.cjs"); + + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(packagedProxy), { recursive: true }); + fs.writeFileSync(packagedProxy, "module.exports = {};\n", "utf8"); + Object.defineProperty(process, "resourcesPath", { + configurable: true, + value: resourcesPath, + }); + + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, + workspaceRoot, + }); + + expect(launch.mode).toBe("bundled_proxy"); + expect(launch.entryPath).toBe(packagedProxy); + expect(launch.cmdArgs[0]).toBe(packagedProxy); + }); +}); diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts new file mode 100644 index 00000000..c2adc209 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts @@ -0,0 +1,195 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; +import type { ComputerUsePolicy } from "../../../shared/types"; + +export type AdeMcpLaunchMode = "bundled_proxy" | "headless_built" | "headless_source"; + +export type AdeMcpLaunch = { + mode: AdeMcpLaunchMode; + command: string; + cmdArgs: string[]; + env: Record; + entryPath: string; + runtimeRoot: string | null; + socketPath: string; + packaged: boolean; + resourcesPath: string | null; +}; + +type AdeMcpLaunchArgs = { + projectRoot?: string; + workspaceRoot: string; + runtimeRoot?: string; + missionId?: string; + runId?: string; + stepId?: string; + attemptId?: string; + defaultRole?: string; + ownerId?: string; + computerUsePolicy?: ComputerUsePolicy | null; + bundledProxyPath?: string; + preferBundledProxy?: boolean; +}; + +function pathExists(targetPath: string | null | undefined): targetPath is string { + return Boolean(targetPath && fs.existsSync(targetPath)); +} + +function resolveBundledProxyPath(overridePath?: string): string | null { + const packagedCandidates = (() => { + const resourcesPath = typeof process.resourcesPath === "string" && process.resourcesPath.trim().length > 0 + ? process.resourcesPath + : null; + if (!resourcesPath) return []; + return [ + path.join(resourcesPath, "app.asar.unpacked", "dist", "main", "adeMcpProxy.cjs"), + path.join(resourcesPath, "dist", "main", "adeMcpProxy.cjs"), + ]; + })(); + const candidates = [ + overridePath, + ...packagedCandidates, + path.join(__dirname, "adeMcpProxy.cjs"), + path.resolve(process.cwd(), "dist", "main", "adeMcpProxy.cjs"), + path.resolve(process.cwd(), "apps", "desktop", "dist", "main", "adeMcpProxy.cjs"), + ]; + + for (const candidate of candidates) { + if (!pathExists(candidate)) continue; + return path.resolve(candidate); + } + + return null; +} + +export function resolveRepoRuntimeRoot(): string { + const startPoints = [ + process.cwd(), + __dirname, + path.resolve(process.cwd(), ".."), + path.resolve(process.cwd(), "..", ".."), + ]; + + for (const start of startPoints) { + let dir = path.resolve(start); + for (let i = 0; i < 12; i += 1) { + if (fs.existsSync(path.join(dir, "apps", "mcp-server", "package.json"))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + + return path.resolve(process.cwd()); +} + +function buildLaunchEnv(args: { + projectRoot: string; + workspaceRoot: string; + missionId?: string; + runId?: string; + stepId?: string; + attemptId?: string; + defaultRole?: string; + ownerId?: string; + socketPath: string; + computerUsePolicy?: ComputerUsePolicy | null; +}): Record { + return { + ADE_PROJECT_ROOT: args.projectRoot, + ADE_WORKSPACE_ROOT: args.workspaceRoot, + ADE_MCP_SOCKET_PATH: args.socketPath, + ADE_MISSION_ID: args.missionId ?? "", + ADE_RUN_ID: args.runId ?? "", + ADE_STEP_ID: args.stepId ?? "", + ADE_ATTEMPT_ID: args.attemptId ?? "", + ADE_DEFAULT_ROLE: args.defaultRole ?? "agent", + ADE_OWNER_ID: args.ownerId ?? "", + ADE_COMPUTER_USE_MODE: args.computerUsePolicy?.mode ?? "", + ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK: + typeof args.computerUsePolicy?.allowLocalFallback === "boolean" + ? (args.computerUsePolicy.allowLocalFallback ? "1" : "0") + : "", + ADE_COMPUTER_USE_RETAIN_ARTIFACTS: + typeof args.computerUsePolicy?.retainArtifacts === "boolean" + ? (args.computerUsePolicy.retainArtifacts ? "1" : "0") + : "", + ADE_COMPUTER_USE_PREFERRED_BACKEND: args.computerUsePolicy?.preferredBackend ?? "", + }; +} + +export function resolveDesktopAdeMcpLaunch(args: AdeMcpLaunchArgs): AdeMcpLaunch { + const projectRoot = typeof args.projectRoot === "string" && args.projectRoot.trim().length > 0 + ? path.resolve(args.projectRoot) + : path.resolve(args.workspaceRoot); + const workspaceRoot = path.resolve(args.workspaceRoot); + const socketPath = resolveAdeLayout(projectRoot).socketPath; + const resourcesPath = typeof process.resourcesPath === "string" && process.resourcesPath.trim().length > 0 + ? process.resourcesPath + : null; + const env = buildLaunchEnv({ + projectRoot, + workspaceRoot, + missionId: args.missionId, + runId: args.runId, + stepId: args.stepId, + attemptId: args.attemptId, + defaultRole: args.defaultRole, + ownerId: args.ownerId, + socketPath, + computerUsePolicy: args.computerUsePolicy, + }); + const bundledProxyPath = args.preferBundledProxy === false ? null : resolveBundledProxyPath(args.bundledProxyPath); + const packaged = __dirname.includes("app.asar"); + + if (bundledProxyPath) { + return { + mode: "bundled_proxy", + command: process.execPath, + cmdArgs: [bundledProxyPath, "--project-root", projectRoot, "--workspace-root", workspaceRoot], + env: { + ...env, + ELECTRON_RUN_AS_NODE: "1", + }, + entryPath: bundledProxyPath, + runtimeRoot: args.runtimeRoot ? path.resolve(args.runtimeRoot) : null, + socketPath, + packaged, + resourcesPath, + }; + } + + const runtimeRoot = path.resolve(args.runtimeRoot ?? resolveRepoRuntimeRoot()); + const mcpServerDir = path.resolve(runtimeRoot, "apps", "mcp-server"); + const builtEntry = path.join(mcpServerDir, "dist", "index.cjs"); + const srcEntry = path.join(mcpServerDir, "src", "index.ts"); + + if (fs.existsSync(builtEntry)) { + return { + mode: "headless_built", + command: "node", + cmdArgs: [builtEntry, "--project-root", projectRoot, "--workspace-root", workspaceRoot], + env, + entryPath: builtEntry, + runtimeRoot, + socketPath, + packaged, + resourcesPath, + }; + } + + return { + mode: "headless_source", + command: "npx", + cmdArgs: ["tsx", srcEntry, "--project-root", projectRoot, "--workspace-root", workspaceRoot], + env, + entryPath: srcEntry, + runtimeRoot, + socketPath, + packaged, + resourcesPath, + }; +} diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 22c6025b..d0a239a2 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -98,7 +98,7 @@ describe("ProvidersSection", () => { expect(ade.ai.listApiKeys).toHaveBeenCalledTimes(1); }); - expect(await screen.findByText("/Users/arul/.local/bin/claude")).toBeTruthy(); + expect((await screen.findAllByText("/Users/arul/.local/bin/claude")).length).toBeGreaterThan(0); act(() => { emitChatEvent?.({ @@ -117,6 +117,18 @@ describe("ProvidersSection", () => { }, { timeout: 2_000 }); expect(await screen.findByText("Sign-In Required")).toBeTruthy(); - expect(screen.getByText("/Users/arul/.local/bin/claude")).toBeTruthy(); + expect(screen.getAllByText("/Users/arul/.local/bin/claude").length).toBeGreaterThan(0); + }); + + it("shows Connected while the provider runtime is launchable", async () => { + render(); + + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); + expect(window.ade.ai.listApiKeys).toHaveBeenCalledTimes(1); + }); + + expect((await screen.findAllByText("Connected")).length).toBeGreaterThan(0); + expect(screen.getAllByText("/Users/arul/.local/bin/claude").length).toBeGreaterThan(0); }); }); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index d93b6d8c..e72bf1c9 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -4,6 +4,10 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "ai": ["./node_modules/ai"] + }, "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts index 2bdea643..f33c987a 100644 --- a/apps/desktop/tsup.config.ts +++ b/apps/desktop/tsup.config.ts @@ -2,7 +2,9 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { + "main/adeMcpProxy": "src/main/adeMcpProxy.ts", "main/main": "src/main/main.ts", + "main/packagedRuntimeSmoke": "src/main/packagedRuntimeSmoke.ts", "preload/preload": "src/preload/preload.ts" }, format: ["cjs"],