From 8f54076190f0fffff42104c5b71450649b1571c6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:46:31 -0400 Subject: [PATCH 1/3] Fix runtime packaging and MCP binary launch on macOS --- .github/workflows/release-core.yml | 2 +- apps/desktop/package.json | 10 +- .../scripts/after-pack-runtime-fixes.cjs | 35 +++ .../scripts/normalize-runtime-binaries.cjs | 14 + .../scripts/prepare-universal-mac-inputs.mjs | 57 +++- apps/desktop/scripts/release-mac-local.mjs | 19 +- .../scripts/require-macos-release-secrets.cjs | 20 +- .../scripts/runtimeBinaryPermissions.cjs | 125 +++++++++ .../scripts/validate-mac-artifacts.mjs | 161 ++++++++++- apps/desktop/src/main/adeMcpProxy.ts | 261 ++++++++++++++++++ apps/desktop/src/main/main.ts | 4 +- apps/desktop/src/main/packagedRuntimeSmoke.ts | 198 +++++++++++++ .../services/ai/claudeRuntimeProbe.test.ts | 15 + .../main/services/ai/claudeRuntimeProbe.ts | 32 +-- .../ai/providerConnectionStatus.test.ts | 63 ++++- .../main/services/chat/agentChatService.ts | 94 +++++-- .../unifiedOrchestratorAdapter.test.ts | 65 ++++- .../unifiedOrchestratorAdapter.ts | 92 ++---- .../main/services/processes/processService.ts | 71 ++++- .../services/runtime/adeMcpLaunch.test.ts | 109 ++++++++ .../src/main/services/runtime/adeMcpLaunch.ts | 195 +++++++++++++ .../settings/ProvidersSection.test.tsx | 16 +- apps/desktop/tsconfig.json | 4 + apps/desktop/tsup.config.ts | 2 + 24 files changed, 1525 insertions(+), 139 deletions(-) create mode 100644 apps/desktop/scripts/after-pack-runtime-fixes.cjs create mode 100644 apps/desktop/scripts/normalize-runtime-binaries.cjs create mode 100644 apps/desktop/scripts/runtimeBinaryPermissions.cjs create mode 100644 apps/desktop/src/main/adeMcpProxy.ts create mode 100644 apps/desktop/src/main/packagedRuntimeSmoke.ts create mode 100644 apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts create mode 100644 apps/desktop/src/main/services/runtime/adeMcpLaunch.ts 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 419752e1..54d0d4a7 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -114,8 +114,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/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts index 582c469d..6f590694 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts @@ -25,6 +25,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), @@ -93,6 +100,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(); expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 3fc5b1de..2d7f53b8 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -1,9 +1,6 @@ -import fs from "node:fs"; -import path from "node:path"; import { query as claudeQuery, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { Logger } from "../logging/logger"; import { getErrorMessage } from "../shared/utils"; -import { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter"; import { reportProviderRuntimeAuthFailure, reportProviderRuntimeFailure, @@ -11,6 +8,7 @@ import { } 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; @@ -27,8 +25,6 @@ type ClaudeRuntimeProbeResult = /** Cache and in-flight probe keyed by projectRoot to avoid cross-project contamination. */ const probeCache = new Map(); const inFlightProbes = new Map>(); -let runtimeRootCache: string | null = null; - function normalizeErrorMessage(error: unknown): string { const text = getErrorMessage(error).trim(); return text.length > 0 ? text : DEFAULT_RUNTIME_FAILURE; @@ -90,30 +86,12 @@ function cacheResult(projectRoot: string, result: ClaudeRuntimeProbeResult): Cla return result; } -function resolveProbeRuntimeRoot(): string { - if (runtimeRootCache !== null) return runtimeRootCache; - 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"))) { - runtimeRootCache = dir; - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } - runtimeRootCache = process.cwd(); - return runtimeRootCache; -} - function resolveProbeMcpServers(projectRoot: string): Record> | undefined { - const launch = resolveAdeMcpServerLaunch({ + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, workspaceRoot: projectRoot, - runtimeRoot: resolveProbeRuntimeRoot(), - defaultRole: "agent", + runtimeRoot: resolveRepoRuntimeRoot(), + defaultRole: "external", }); return normalizeCliMcpServers("claude", { ade: { diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index 765d52ec..cd1d1416 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -125,21 +125,62 @@ describe("buildProviderConnections", () => { 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: string) => { + 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("treats runtime probe failures as launch blockers", async () => { mockState.readClaudeCredentials.mockResolvedValue({ accessToken: "token", source: "claude-credentials-file", }); - mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => ( - provider === "claude" - ? { - provider: "claude", - state: "runtime-failed", - message: "ADE could not launch Claude from this app session.", - checkedAt: new Date().toISOString(), - } - : null - )); + mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => { + 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([ { @@ -161,6 +202,6 @@ describe("buildProviderConnections", () => { 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 Claude from this app session."); + 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/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 37386d19..c03f46ea 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 { @@ -109,7 +110,7 @@ import type { createLinearDispatcherService } from "../cto/linearDispatcherServi import type { createPrService } from "../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { createProofObserver } from "../computerUse/proofObserver"; -import { resolveAdeMcpServerLaunch } from "../orchestrator/unifiedOrchestratorAdapter"; +import { resolveAdeMcpServerLaunch, resolveUnifiedRuntimeRoot } from "../orchestrator/unifiedOrchestratorAdapter"; import type { createMissionService } from "../missions/missionService"; import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; @@ -1083,20 +1084,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; } @@ -1349,6 +1337,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"); @@ -4681,6 +4692,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"] @@ -4787,7 +4809,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, }); }); @@ -4924,6 +4977,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, @@ -4932,6 +4986,7 @@ export function createAgentChatService(args: { promptSuggestions: true, maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, model: resolveClaudeCliModel(managed.session.model), + pathToClaudeCodeExecutable: claudeExecutable.path, }; if (!lightweight) { opts.systemPrompt = { @@ -5055,6 +5110,7 @@ export function createAgentChatService(args: { sessionId: managed.session.id, resume: !!runtime.sdkSessionId, model: v2Opts.model, + claudeExecutablePath: v2Opts.pathToClaudeCodeExecutable, }); if (runtime.v2WarmupCancelled) return; @@ -5132,6 +5188,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 e5a6b2f1..1197628d 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -26,6 +26,7 @@ import { normalizeMissionPermissions, providerPermissionsToLegacyConfig, } from "./permissionMapping"; +import { resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "../runtime/adeMcpLaunch"; /** * Build environment variable assignments for worker identity. @@ -66,55 +67,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_CHAT_SESSION_ID: args.chatSessionId ?? "", - 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 { @@ -245,26 +224,7 @@ function writeWorkerPromptFile(args: { * Walks up from cwd looking for package.json with the monorepo marker. */ export function resolveUnifiedRuntimeRoot(): 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++) { - 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(); } /** @@ -282,6 +242,8 @@ export function buildCodexMcpConfigFlags(args: { attemptId?: string; ownerId?: string | null; defaultRole?: string; + bundledProxyPath?: string; + preferBundledProxy?: boolean; }): string[] { const launch = resolveAdeMcpServerLaunch({ projectRoot: args.projectRoot, @@ -293,6 +255,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 diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index d03d035f..12d5cbac 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, @@ -744,11 +788,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; } @@ -786,7 +847,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/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"], From 95be6ee99d584ccb55cd61a4e29413f321ff1ea1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:45:34 -0400 Subject: [PATCH 2/3] Add tests, simplify code, update docs for runtime/MCP changes - Extract adeMcpProxy parsing utilities into testable module (52 tests) - Fix duplicate vi.mock in claudeRuntimeProbe.test.ts causing stale assertion - Add missing mocks for resolveUnifiedRuntimeRoot and resolveClaudeCodeExecutable in agentChatService.test.ts - Simplify identity injection loop, runtime root resolution, and resource path helpers - Fix stale JSDoc on resolveUnifiedRuntimeRoot, inline single-use variable in processService - Update docs for MCP launch modes, build entry points, runtime hardening, and diagnostic logging Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/main/adeMcpProxy.ts | 152 +------ .../desktop/src/main/adeMcpProxyUtils.test.ts | 384 ++++++++++++++++++ apps/desktop/src/main/adeMcpProxyUtils.ts | 115 ++++++ .../services/ai/claudeRuntimeProbe.test.ts | 9 +- .../main/services/ai/claudeRuntimeProbe.ts | 1 + .../services/chat/agentChatService.test.ts | 5 + .../unifiedOrchestratorAdapter.ts | 5 +- .../main/services/processes/processService.ts | 3 +- .../src/main/services/runtime/adeMcpLaunch.ts | 28 +- docs/architecture/AI_INTEGRATION.md | 22 +- docs/architecture/DESKTOP_APP.md | 22 +- docs/architecture/SYSTEM_OVERVIEW.md | 2 +- docs/features/CHAT.md | 14 +- 13 files changed, 588 insertions(+), 174 deletions(-) create mode 100644 apps/desktop/src/main/adeMcpProxyUtils.test.ts create mode 100644 apps/desktop/src/main/adeMcpProxyUtils.ts diff --git a/apps/desktop/src/main/adeMcpProxy.ts b/apps/desktop/src/main/adeMcpProxy.ts index 61c1de3b..4b679f81 100644 --- a/apps/desktop/src/main/adeMcpProxy.ts +++ b/apps/desktop/src/main/adeMcpProxy.ts @@ -3,6 +3,13 @@ import net from "node:net"; import path from "node:path"; import { Buffer } from "node:buffer"; import { resolveAdeLayout } from "../shared/adeLayout"; +import { + asTrimmed, + hasProxyIdentity, + injectIdentityIntoInitializePayload, + takeNextInboundMessage, + type ProxyIdentity, +} from "./adeMcpProxyUtils"; process.env.ADE_STDIO_TRANSPORT ??= "1"; @@ -11,21 +18,6 @@ type RuntimeRoots = { 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) { @@ -41,28 +33,16 @@ 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 resolveRoot(envKey: string, flag: string, fallback: string): string { + const fromEnv = process.env[envKey]?.trim(); + if (fromEnv) return path.resolve(fromEnv); + return resolveCliArg(flag) ?? fallback; } -function asTrimmed(value: string | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; +function resolveRuntimeRoots(): RuntimeRoots { + const projectRoot = resolveRoot("ADE_PROJECT_ROOT", "--project-root", process.cwd()); + const workspaceRoot = resolveRoot("ADE_WORKSPACE_ROOT", "--workspace-root", projectRoot); + return { projectRoot, workspaceRoot }; } function resolveProxyIdentityFromEnv(): ProxyIdentity { @@ -75,108 +55,6 @@ function resolveProxyIdentityFromEnv(): ProxyIdentity { }; } -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)) { diff --git a/apps/desktop/src/main/adeMcpProxyUtils.test.ts b/apps/desktop/src/main/adeMcpProxyUtils.test.ts new file mode 100644 index 00000000..f13444f5 --- /dev/null +++ b/apps/desktop/src/main/adeMcpProxyUtils.test.ts @@ -0,0 +1,384 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; +import { + asTrimmed, + findHeaderBoundary, + hasProxyIdentity, + injectIdentityIntoInitializePayload, + isRecord, + parseContentLength, + takeNextInboundMessage, + type ProxyIdentity, +} from "./adeMcpProxyUtils"; + +const NULL_IDENTITY: ProxyIdentity = { + missionId: null, + runId: null, + stepId: null, + attemptId: null, + role: null, +}; + +describe("asTrimmed", () => { + it("returns trimmed string for valid input", () => { + expect(asTrimmed(" hello ")).toBe("hello"); + }); + + it("returns the string unchanged when already trimmed", () => { + expect(asTrimmed("hello")).toBe("hello"); + }); + + it("returns null for undefined", () => { + expect(asTrimmed(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(asTrimmed("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(asTrimmed(" ")).toBeNull(); + expect(asTrimmed("\t\n")).toBeNull(); + }); +}); + +describe("isRecord", () => { + it("returns true for a plain object", () => { + expect(isRecord({ a: 1 })).toBe(true); + }); + + it("returns true for an empty object", () => { + expect(isRecord({})).toBe(true); + }); + + it("returns false for null", () => { + expect(isRecord(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isRecord(undefined)).toBe(false); + }); + + it("returns false for an array", () => { + expect(isRecord([1, 2, 3])).toBe(false); + }); + + it("returns false for a string", () => { + expect(isRecord("hello")).toBe(false); + }); + + it("returns false for a number", () => { + expect(isRecord(42)).toBe(false); + }); + + it("returns false for a boolean", () => { + expect(isRecord(true)).toBe(false); + }); +}); + +describe("hasProxyIdentity", () => { + it("returns false when all fields are null", () => { + expect(hasProxyIdentity(NULL_IDENTITY)).toBe(false); + }); + + it("returns true when missionId is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, missionId: "m-1" })).toBe(true); + }); + + it("returns true when runId is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, runId: "r-1" })).toBe(true); + }); + + it("returns true when stepId is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, stepId: "s-1" })).toBe(true); + }); + + it("returns true when attemptId is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, attemptId: "a-1" })).toBe(true); + }); + + it("returns true when role is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, role: "coder" })).toBe(true); + }); + + it("returns true when multiple fields are set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, missionId: "m-1", role: "coder" })).toBe(true); + }); +}); + +describe("findHeaderBoundary", () => { + it("finds CRLF boundary (\\r\\n\\r\\n)", () => { + const buf = Buffer.from("Content-Length: 10\r\n\r\n{\"id\":1}"); + const result = findHeaderBoundary(buf); + expect(result).toEqual({ index: 18, delimiterLength: 4 }); + }); + + it("finds LF boundary (\\n\\n)", () => { + const buf = Buffer.from("Content-Length: 10\n\n{\"id\":1}"); + const result = findHeaderBoundary(buf); + expect(result).toEqual({ index: 18, delimiterLength: 2 }); + }); + + it("picks the earlier boundary when both are present (CRLF first)", () => { + const buf = Buffer.from("A\r\n\r\nB\n\nC"); + const result = findHeaderBoundary(buf); + expect(result).toEqual({ index: 1, delimiterLength: 4 }); + }); + + it("picks the earlier boundary when both are present (LF first)", () => { + const buf = Buffer.from("A\n\nB\r\n\r\nC"); + const result = findHeaderBoundary(buf); + expect(result).toEqual({ index: 1, delimiterLength: 2 }); + }); + + it("returns null when no boundary is found", () => { + const buf = Buffer.from("Content-Length: 10\r\n{\"id\":1}"); + expect(findHeaderBoundary(buf)).toBeNull(); + }); + + it("returns null for empty buffer", () => { + expect(findHeaderBoundary(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe("parseContentLength", () => { + it("parses valid Content-Length header", () => { + expect(parseContentLength("Content-Length: 42")).toBe(42); + }); + + it("handles case-insensitive matching", () => { + expect(parseContentLength("content-length: 99")).toBe(99); + expect(parseContentLength("CONTENT-LENGTH: 7")).toBe(7); + expect(parseContentLength("Content-length: 123")).toBe(123); + }); + + it("handles extra whitespace around colon", () => { + expect(parseContentLength("Content-Length : 55")).toBe(55); + }); + + it("parses from multi-line header block", () => { + const block = "X-Custom: foo\r\nContent-Length: 128\r\nX-Other: bar"; + expect(parseContentLength(block)).toBe(128); + }); + + it("returns null when header is missing", () => { + expect(parseContentLength("X-Custom: foo")).toBeNull(); + }); + + it("returns null for non-numeric value", () => { + expect(parseContentLength("Content-Length: abc")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseContentLength("")).toBeNull(); + }); +}); + +describe("takeNextInboundMessage", () => { + it("parses a JSONL message (starts with {)", () => { + const json = '{"jsonrpc":"2.0","method":"initialize","id":1}'; + const buf = Buffer.from(json + "\n"); + const result = takeNextInboundMessage(buf); + expect(result).not.toBeNull(); + expect(result!.transport).toBe("jsonl"); + expect(result!.payloadText).toBe(json); + expect(result!.rest.length).toBe(0); + }); + + it("parses a JSONL message (starts with [)", () => { + const json = '[{"jsonrpc":"2.0","id":1}]'; + const buf = Buffer.from(json + "\n"); + const result = takeNextInboundMessage(buf); + expect(result).not.toBeNull(); + expect(result!.transport).toBe("jsonl"); + expect(result!.payloadText).toBe(json); + }); + + it("returns null for incomplete JSONL (no newline)", () => { + const buf = Buffer.from('{"jsonrpc":"2.0","id":1}'); + expect(takeNextInboundMessage(buf)).toBeNull(); + }); + + it("correctly separates payload from rest in JSONL", () => { + const msg1 = '{"id":1}\n'; + const msg2 = '{"id":2}\n'; + const buf = Buffer.from(msg1 + msg2); + const result = takeNextInboundMessage(buf); + expect(result).not.toBeNull(); + expect(result!.payloadText).toBe('{"id":1}'); + expect(result!.rest.toString("utf8")).toBe(msg2); + }); + + it("parses a framed message with Content-Length header (CRLF)", () => { + const body = '{"jsonrpc":"2.0","method":"test","id":2}'; + const frame = `Content-Length: ${body.length}\r\n\r\n${body}`; + const buf = Buffer.from(frame); + const result = takeNextInboundMessage(buf); + expect(result).not.toBeNull(); + expect(result!.transport).toBe("framed"); + expect(result!.payloadText).toBe(body); + expect(result!.rest.length).toBe(0); + }); + + it("parses a framed message with Content-Length header (LF)", () => { + const body = '{"jsonrpc":"2.0","method":"test","id":3}'; + const frame = `Content-Length: ${body.length}\n\n${body}`; + const buf = Buffer.from(frame); + const result = takeNextInboundMessage(buf); + expect(result).not.toBeNull(); + expect(result!.transport).toBe("framed"); + expect(result!.payloadText).toBe(body); + }); + + it("returns null for empty buffer", () => { + expect(takeNextInboundMessage(Buffer.alloc(0))).toBeNull(); + }); + + it("returns null for incomplete framed message (body too short)", () => { + const frame = "Content-Length: 100\r\n\r\nshort"; + const buf = Buffer.from(frame); + expect(takeNextInboundMessage(buf)).toBeNull(); + }); + + it("returns null for framed message with missing Content-Length", () => { + const frame = "X-Other: value\r\n\r\n{\"id\":1}"; + const buf = Buffer.from(frame); + expect(takeNextInboundMessage(buf)).toBeNull(); + }); + + it("correctly separates payload from rest in framed messages", () => { + const body1 = '{"id":1}'; + const body2 = '{"id":2}'; + const frame1 = `Content-Length: ${body1.length}\r\n\r\n${body1}`; + const frame2 = `Content-Length: ${body2.length}\r\n\r\n${body2}`; + const buf = Buffer.from(frame1 + frame2); + const result = takeNextInboundMessage(buf); + expect(result).not.toBeNull(); + expect(result!.payloadText).toBe(body1); + expect(result!.rest.toString("utf8")).toBe(frame2); + }); +}); + +describe("injectIdentityIntoInitializePayload", () => { + const identity: ProxyIdentity = { + missionId: "m-1", + runId: "r-1", + stepId: "s-1", + attemptId: "a-1", + role: "coder", + }; + + it("injects identity into initialize method", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: {}, + }); + const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.identity).toEqual({ + missionId: "m-1", + runId: "r-1", + stepId: "s-1", + attemptId: "a-1", + role: "coder", + }); + }); + + it("does NOT modify non-initialize methods", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "tools/list", + id: 2, + params: {}, + }); + const result = injectIdentityIntoInitializePayload(payload, identity); + expect(result).toBe(payload); + }); + + it("merges with existing identity without overwriting existing fields", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + identity: { + missionId: "existing-mission", + role: "existing-role", + }, + }, + }); + const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.identity.missionId).toBe("existing-mission"); + expect(result.params.identity.role).toBe("existing-role"); + expect(result.params.identity.runId).toBe("r-1"); + expect(result.params.identity.stepId).toBe("s-1"); + expect(result.params.identity.attemptId).toBe("a-1"); + }); + + it("overwrites existing identity fields that are empty strings", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + identity: { + missionId: " ", + role: "", + }, + }, + }); + const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.identity.missionId).toBe("m-1"); + expect(result.params.identity.role).toBe("coder"); + }); + + it("returns original text for invalid JSON", () => { + const broken = "this is not json {{{"; + expect(injectIdentityIntoInitializePayload(broken, identity)).toBe(broken); + }); + + it("returns original text when identity has no fields set", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: {}, + }); + expect(injectIdentityIntoInitializePayload(payload, NULL_IDENTITY)).toBe(payload); + }); + + it("returns original text when payload is not an object", () => { + expect(injectIdentityIntoInitializePayload('"just a string"', identity)).toBe('"just a string"'); + }); + + it("creates params object when params is missing", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + }); + const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.identity).toEqual({ + missionId: "m-1", + runId: "r-1", + stepId: "s-1", + attemptId: "a-1", + role: "coder", + }); + }); + + it("preserves other params fields alongside identity", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + capabilities: { tools: true }, + }, + }); + const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.capabilities).toEqual({ tools: true }); + expect(result.params.identity.missionId).toBe("m-1"); + }); +}); diff --git a/apps/desktop/src/main/adeMcpProxyUtils.ts b/apps/desktop/src/main/adeMcpProxyUtils.ts new file mode 100644 index 00000000..06f1f8fe --- /dev/null +++ b/apps/desktop/src/main/adeMcpProxyUtils.ts @@ -0,0 +1,115 @@ +import type { Buffer } from "node:buffer"; + +export type ProxyIdentity = { + missionId: string | null; + runId: string | null; + stepId: string | null; + attemptId: string | null; + role: string | null; +}; + +export type ParsedInboundMessage = { + transport: "jsonl" | "framed"; + payloadText: string; + raw: Buffer; + rest: Buffer; +}; + +export function asTrimmed(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function hasProxyIdentity(identity: ProxyIdentity): boolean { + return Boolean(identity.missionId || identity.runId || identity.stepId || identity.attemptId || identity.role); +} + +export 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 }; +} + +export 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; +} + +export 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), + }; +} + +export 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 }; + + const identityKeys = ["missionId", "runId", "stepId", "attemptId", "role"] as const; + for (const key of identityKeys) { + if (!identity[key]) continue; + const existing = existingIdentity[key]; + if (typeof existing === "string" && existing.trim()) continue; + mergedIdentity[key] = identity[key]; + } + + return JSON.stringify({ + ...payload, + params: { + ...params, + identity: mergedIdentity, + }, + }); +} diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts index 6f590694..fe110b82 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts @@ -25,13 +25,6 @@ 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), @@ -102,7 +95,7 @@ describe("claudeRuntimeProbe", () => { expect(query.close).toHaveBeenCalledTimes(1); expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ - pathToClaudeCodeExecutable: "/mock/bin/claude", + pathToClaudeCodeExecutable: "/usr/local/bin/claude", mcpServers: expect.objectContaining({ ade: expect.any(Object), }), diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 2d7f53b8..645094fc 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -25,6 +25,7 @@ type ClaudeRuntimeProbeResult = /** Cache and in-flight probe keyed by projectRoot to avoid cross-project contamination. */ const probeCache = new Map(); const inFlightProbes = new Map>(); + function normalizeErrorMessage(error: unknown): string { const text = getErrorMessage(error).trim(); return text.length > 0 ? text : DEFAULT_RUNTIME_FAILURE; diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 37ffafc5..5b5088e1 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -113,6 +113,10 @@ vi.mock("../ai/claudeRuntimeProbe", () => ({ isClaudeRuntimeAuthError: vi.fn(() => false), })); +vi.mock("../ai/claudeCodeExecutable", () => ({ + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), +})); + vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []), })); @@ -127,6 +131,7 @@ vi.mock("../orchestrator/unifiedOrchestratorAdapter", () => ({ cmdArgs: [], env: {}, })), + resolveUnifiedRuntimeRoot: vi.fn(() => process.cwd()), })); vi.mock("../orchestrator/permissionMapping", () => ({ diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts index 1197628d..b77ee7e9 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -219,10 +219,7 @@ function writeWorkerPromptFile(args: { return promptPath; } -/** - * Resolve the project root from the current working directory. - * Walks up from cwd looking for package.json with the monorepo marker. - */ +/** Resolve the monorepo runtime root (delegates to the shared adeMcpLaunch resolver). */ export function resolveUnifiedRuntimeRoot(): string { return resolveRepoRuntimeRoot(); } diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index 12d5cbac..2a0f2594 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -714,7 +714,6 @@ export function createProcessService({ 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; @@ -742,7 +741,7 @@ export function createProcessService({ envPath: process.env.PATH ?? "", envShell: process.env.SHELL ?? "", resourcesPath: process.resourcesPath ?? "", - error: message, + error: error instanceof Error ? error.message : String(error), }); }; diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts index c2adc209..6ae8291d 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts @@ -36,20 +36,22 @@ function pathExists(targetPath: string | null | undefined): targetPath is string return Boolean(targetPath && fs.existsSync(targetPath)); } +function resolveResourcesPath(): string | null { + return typeof process.resourcesPath === "string" && process.resourcesPath.trim().length > 0 + ? process.resourcesPath + : null; +} + 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 resourcesPath = resolveResourcesPath(); const candidates = [ overridePath, - ...packagedCandidates, + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar.unpacked", "dist", "main", "adeMcpProxy.cjs"), + path.join(resourcesPath, "dist", "main", "adeMcpProxy.cjs"), + ] + : []), path.join(__dirname, "adeMcpProxy.cjs"), path.resolve(process.cwd(), "dist", "main", "adeMcpProxy.cjs"), path.resolve(process.cwd(), "apps", "desktop", "dist", "main", "adeMcpProxy.cjs"), @@ -127,9 +129,7 @@ export function resolveDesktopAdeMcpLaunch(args: AdeMcpLaunchArgs): AdeMcpLaunch : 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 resourcesPath = resolveResourcesPath(); const env = buildLaunchEnv({ projectRoot, workspaceRoot, diff --git a/docs/architecture/AI_INTEGRATION.md b/docs/architecture/AI_INTEGRATION.md index 7a6f76a0..4a2dbde5 100644 --- a/docs/architecture/AI_INTEGRATION.md +++ b/docs/architecture/AI_INTEGRATION.md @@ -181,7 +181,7 @@ All AI task dispatching flows through the unified executor (`unifiedExecutor.ts` > **Note**: The legacy `ClaudeExecutor` and `CodexExecutor` classes have been deleted. All AI worker execution now routes through a single `unified` executor kind. See `docs/ORCHESTRATOR_OVERHAUL.md` for the current runtime contract. The unified runtime supports three model classes: -- **CLI-wrapped** (Claude CLI, Codex CLI): Spawned as subprocesses. Authentication inherits from user's existing CLI login (`claude login`, Codex subscription). MCP server injected via `--mcp-config` flag, with worker-local MCP config mirrored into each worker CWD to support native teammate inheritance paths. +- **CLI-wrapped** (Claude CLI, Codex CLI): Spawned as subprocesses. Authentication inherits from user's existing CLI login (`claude login`, Codex subscription). MCP server injected via `--mcp-config` flag, with worker-local MCP config mirrored into each worker CWD to support native teammate inheritance paths. In packaged builds, the MCP connection uses a bundled proxy binary (`adeMcpProxy.cjs`) that relays stdio over the desktop's Unix socket, avoiding the need for a separate headless MCP server process. - **API/key models** (Anthropic API, OpenAI, Google, Mistral, DeepSeek, xAI, OpenRouter): In-process execution via Vercel AI SDK `streamText()`. Authentication via configured API keys. - **Local models** (Ollama, LM Studio, vLLM): In-process execution via OpenAI-compatible endpoints. @@ -259,7 +259,7 @@ On startup and project switch, the AI integration service probes for available p - **`providerCredentialSources.ts`**: Reads local credential files (Claude OAuth credentials, Codex auth tokens, macOS Keychain) and checks token freshness. - **`providerConnectionStatus.ts`**: Builds a structured `AiProviderConnections` object with per-provider `authAvailable`, `runtimeDetected`, `runtimeAvailable`, `usageAvailable`, `blocker`, and `sources` fields. Both `auth-failed` and `runtime-failed` health states now mark a provider as not runtime-available, with distinct blocker messages for each failure mode. - **`providerRuntimeHealth.ts`**: Tracks runtime health state (`ready`, `auth-failed`, `runtime-failed`) per provider. Health version increments on state changes, invalidating the status cache. -- **`claudeRuntimeProbe.ts`**: On forced refresh, performs a lightweight Claude Agent SDK query to confirm the Claude runtime can authenticate and start from the current app session. The probe resolves the Claude Code executable path via `claudeCodeExecutable.ts` and injects a minimal ADE MCP server configuration so the probe runs under conditions closer to real session startup. +- **`claudeRuntimeProbe.ts`**: On forced refresh, performs a lightweight Claude Agent SDK query to confirm the Claude runtime can authenticate and start from the current app session. The probe resolves the Claude Code executable path via `claudeCodeExecutable.ts` and uses the centralized `resolveDesktopAdeMcpLaunch()` from `adeMcpLaunch.ts` to inject a minimal ADE MCP server configuration so the probe runs under conditions closer to real session startup. - **`claudeCodeExecutable.ts`**: Resolves the Claude Code CLI binary path, consulting detected auth sources for known installation locations. Used by both the runtime probe and the provider resolver to ensure consistent executable discovery. If no usable provider is detected, ADE operates in guest mode: all deterministic features (packs, diffs, conflict detection) work normally, but AI-generated content (narratives, proposals, PR descriptions) is unavailable. The UI clearly indicates which features require a CLI subscription. @@ -382,6 +382,18 @@ The MCP server is a standalone package (`apps/mcp-server`) that exposes ADE's in - **Smart entry point**: Auto-detects `.ade/mcp.sock` to choose proxy (embedded) vs headless mode - **Session identity**: The MCP server propagates a `chatSessionId` field through `SessionIdentity`, resolved from the `ADE_CHAT_SESSION_ID` environment variable or the `initialize` handshake params. This links MCP tool calls back to their originating chat session for artifact ownership, computer use proof association, and audit logging. For standalone chat sessions (no mission/run/step context), the server infers the chat session from the caller ID when not explicitly provided. +#### MCP Launch Resolution + +Worker and chat processes connect to ADE's MCP server through one of three launch modes, resolved by `resolveDesktopAdeMcpLaunch()` in `adeMcpLaunch.ts`: + +| Mode | Binary | When used | +|------|--------|-----------| +| `bundled_proxy` | `adeMcpProxy.cjs` run via Electron's Node | Packaged desktop builds where the proxy binary exists alongside the app bundle. The proxy connects to the desktop's `.ade/mcp.sock` Unix socket and relays stdio, injecting worker identity (mission/run/step/attempt) into the MCP `initialize` handshake. | +| `headless_built` | `apps/mcp-server/dist/index.cjs` via `node` | Development or CI environments where the MCP server has been pre-built but no bundled proxy is available. | +| `headless_source` | `apps/mcp-server/src/index.ts` via `npx tsx` | Development environments where only TypeScript source is available. | + +The launch resolver checks candidates in order: bundled proxy path (from `process.resourcesPath`, `__dirname`, or CWD), then the built MCP entry, then the source entry. Both `unifiedOrchestratorAdapter.ts` (worker spawning) and `claudeRuntimeProbe.ts` (provider health probing) delegate to this centralized resolver to ensure consistent MCP launch behavior across all call sites. The probe also supports a `--probe` flag that returns a JSON diagnostic without establishing a full connection. + #### Available Tools | Tool | Description | Mutation | @@ -613,7 +625,7 @@ For each step that enters the `claimed` state, the orchestrator spawns a worker - An **ADE tool profile** — coordinator/MCP/reporting tools are restricted to those appropriate for the worker role and phase. - A **permission mode** — for CLI-backed models this governs native behavior (`plan`/read-only vs edit/full execution); for API-key/local models it selects ADE's planning/coding tool profiles. -3. **ADE Tool Connection**: Workers receive ADE-owned tools through the current runtime surface. CLI-backed workers commonly connect through the ADE MCP server, while API/local models use ADE's in-process planning/coding tools. In both cases, ADE enforces claimed scope (lane + file patterns) for ADE-owned actions. +3. **ADE Tool Connection**: Workers receive ADE-owned tools through the current runtime surface. CLI-backed workers commonly connect through the ADE MCP server (via the bundled proxy in packaged builds or the headless MCP server in development), while API/local models use ADE's in-process planning/coding tools. In both cases, ADE enforces claimed scope (lane + file patterns) for ADE-owned actions. 4. **Session Tracking**: Worker execution attempts are registered as tracked sessions/attempts for transcript capture, delta computation, and pack integration — the same lifecycle guarantees as interactive chat sessions. @@ -1793,7 +1805,7 @@ W7 builds an extraction and materialization layer on top of the Unified Memory S | Chat UI components | Complete | AgentChatPane, AgentChatMessageList, AgentChatComposer | | Chat session integration | Complete | `codex-chat`, `claude-chat`, and `ai-chat` tool types in `terminal_sessions` | | MCP server (`apps/mcp-server`) | Complete | JSON-RPC 2.0 server with 35 tools, dual-mode architecture (headless + embedded) | -| MCP dual-mode architecture | Complete | Transport abstraction (stdio/socket), headless AI via aiIntegrationService, desktop socket embedding (.ade/mcp.sock), smart entry point auto-detection | +| MCP dual-mode architecture | Complete | Transport abstraction (stdio/socket), headless AI via aiIntegrationService, desktop socket embedding (.ade/mcp.sock), smart entry point auto-detection. Centralized launch resolution (`adeMcpLaunch.ts`) with bundled proxy mode for packaged builds. | | AI orchestrator (Claude + MCP) | Complete | Tasks 1-7 shipped; Orchestrator Overhaul Phases 1-9 complete (reflection protocol, adaptive runtime, UI overhaul). V1 closeout: coordinator finalization awareness. M4/M5 additions: approval gates, mandatory planning enforcement, multi-round deliberation, adaptive runtime, model downgrade, budget-gated spawns, benign error classification. | | Phase 4 orchestrator delegation/team runtime | Complete | `delegate_parallel`, push sub-agent progress/completion rollups, native teammate auto-registration + allocation cap guardrails, single team-member data path | | Adaptive Runtime (M5) | Complete | `adaptiveRuntime.ts` — `classifyTaskComplexity`, `scaleParallelismCap`, `evaluateModelDowngrade`; budget hard cap enforcement in coordinator tools | @@ -1836,7 +1848,7 @@ W7 builds an extraction and materialization layer on top of the Unified Memory S | Task agents (lane artifacts) | Planned | Phase 4 -- specialized agents for artifact production within lanes | | Chat-to-mission escalation | Planned | Phase 4 -- promote a chat conversation into a full mission with pre-filled context | -**Overall status**: Phases 1, 1.5, 2, 3, 4, and 5 are complete. All Phase 4 workstreams (W1-W10, W-UX, W6-half, W7a-c) are shipped at baseline or better. The remaining unshipped work is concentrated in computer-use runtime follow-through (the MCP tool loop for `screenshot_environment`/`interact_gui`/`record_environment` and automatic PR proof embedding) and future-phase items (multi-device sync, remote host deployment, iOS companion). MCP dual-mode architecture is shipped, enabling headless operation with full AI via `aiIntegrationService` and embedded proxy mode through the desktop socket at `.ade/mcp.sock`. +**Overall status**: Phases 1, 1.5, 2, 3, 4, and 5 are complete. All Phase 4 workstreams (W1-W10, W-UX, W6-half, W7a-c) are shipped at baseline or better. The remaining unshipped work is concentrated in computer-use runtime follow-through (the MCP tool loop for `screenshot_environment`/`interact_gui`/`record_environment` and automatic PR proof embedding) and future-phase items (multi-device sync, remote host deployment, iOS companion). MCP dual-mode architecture is shipped, enabling headless operation with full AI via `aiIntegrationService` and embedded proxy mode through the desktop socket at `.ade/mcp.sock`. Packaged macOS builds use a bundled MCP proxy (`adeMcpProxy.cjs`) that connects to the desktop socket, with a runtime smoke test (`packagedRuntimeSmoke.ts`) that validates PTY, Claude SDK, and MCP proxy availability at build time. --- diff --git a/docs/architecture/DESKTOP_APP.md b/docs/architecture/DESKTOP_APP.md index ce63c205..4b353aa2 100644 --- a/docs/architecture/DESKTOP_APP.md +++ b/docs/architecture/DESKTOP_APP.md @@ -46,6 +46,7 @@ Main-process responsibilities include: - PR and GitHub/Linear integration services - memory lifecycle, digest, and embedding services - external MCP, OpenClaw, and automation ingress services +- bundled MCP proxy for packaged builds (relays worker stdio over `.ade/mcp.sock`) - dev tools detection (git, gh CLI availability) - multi-device sync service (cr-sqlite replication, WebSocket host/peer, device registry) @@ -57,6 +58,25 @@ The renderer in `apps/desktop/src/renderer` renders feature surfaces and delegat `apps/desktop/src/preload/preload.ts` exposes a typed `window.ade` contract. `contextIsolation` remains enabled and `nodeIntegration` remains disabled. +### Build entry points + +The desktop build (`tsup.config.ts`) produces three CJS entry points: + +| Entry | Source | Purpose | +|-------|--------|---------| +| `main/main.cjs` | `src/main/main.ts` | Electron main process | +| `main/adeMcpProxy.cjs` | `src/main/adeMcpProxy.ts` | Bundled MCP proxy binary for packaged builds. Runs as a standalone Node process (via `ELECTRON_RUN_AS_NODE=1`) that relays stdio over the desktop's `.ade/mcp.sock` Unix socket. Injects worker identity (mission/run/step/attempt) into the MCP `initialize` handshake. Supports a `--probe` flag for diagnostic checks. | +| `main/packagedRuntimeSmoke.cjs` | `src/main/packagedRuntimeSmoke.ts` | Packaged runtime smoke test. Validates that PTY spawning, Claude Agent SDK startup, Codex SDK availability, and the MCP proxy probe all function correctly within the packaged app bundle. | +| `preload/preload.cjs` | `src/preload/preload.ts` | Renderer preload bridge | + +### Packaged runtime hardening + +Packaged macOS builds include additional post-packaging steps to ensure native binaries function correctly: + +- **Binary permissions** (`scripts/runtimeBinaryPermissions.cjs`): Ensures executable permissions on `node-pty` spawn helpers, Codex CLI vendor binaries, and Claude SDK ripgrep helpers. Also patches `node-pty`'s `unixTerminal.js` to resolve `.asar.unpacked` paths correctly in Electron's ASAR archive environment. +- **Runtime validation** (`scripts/validate-mac-artifacts.mjs`): Validates that packaged artifacts contain expected binaries and that code signing is intact. +- **After-pack fixes** (`scripts/after-pack-runtime-fixes.cjs`): Applies binary permission normalization as an electron-builder after-pack hook. + --- ## Startup lifecycle @@ -65,7 +85,7 @@ The renderer in `apps/desktop/src/renderer` renders feature surfaces and delegat Before ADE creates services or child processes, the main process normalizes the shell `PATH` and applies Electron runtime switches: -- `fixElectronShellPath()` repairs shell resolution on macOS and dev machines. +- `fixElectronShellPath()` repairs shell resolution on macOS and dev machines. The check detects both Homebrew paths (`/usr/local/bin`, `/opt/homebrew/bin`) and user-local paths (`.local/bin`) independently, so the fix activates when either set is missing rather than requiring both to be absent. - Hardware acceleration is disabled only when `ADE_DISABLE_HARDWARE_ACCEL=1` is explicitly set. Dev mode no longer automatically disables hardware acceleration -- this reduces GPU-related dev instability reports while keeping acceleration available for normal development. - Dev builds disable the renderer HTTP cache to avoid stale Vite optimized-dependency artifacts. diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index 0dd2a4df..bf3e7246 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -69,7 +69,7 @@ ADE remains provider-flexible: - API-key/OpenRouter providers - local OpenAI-compatible endpoints -The orchestrator, agent chat, and CTO all use those provider paths through ADE's runtime contracts rather than a hosted ADE backend. +The orchestrator, agent chat, and CTO all use those provider paths through ADE's runtime contracts rather than a hosted ADE backend. MCP server connectivity is resolved through a centralized launch module (`adeMcpLaunch.ts`) that selects between a bundled proxy (packaged builds), a pre-built headless server, or TypeScript source execution depending on the runtime environment. ### 4. Memory architecture diff --git a/docs/features/CHAT.md b/docs/features/CHAT.md index a1c10cfa..cfff9c9d 100644 --- a/docs/features/CHAT.md +++ b/docs/features/CHAT.md @@ -12,8 +12,8 @@ Chat sessions are provider-agnostic. The `AgentChatProvider` type accepts: | Provider key | Runtime | Notes | |---|---|---| -| `claude` | Claude Agent SDK V2 (`@anthropic-ai/claude-agent-sdk`) | Persistent session via `unstable_v2_createSession` — subprocess + MCP servers stay alive between turns. Supports inline image content blocks (base64) for image attachments. | -| `codex` | OpenAI Codex CLI | Persistent subprocess, communicates over JSON-RPC | +| `claude` | Claude Agent SDK V2 (`@anthropic-ai/claude-agent-sdk`) | Persistent session via `unstable_v2_createSession` — subprocess + MCP servers stay alive between turns. Supports inline image content blocks (base64) for image attachments. The Claude Code executable path is resolved via `claudeCodeExecutable.ts` and passed to the SDK at session creation. | +| `codex` | OpenAI Codex CLI | Persistent subprocess (`codex app-server`), communicates over JSON-RPC. Spawn failures are caught and surfaced as error events to the user, with the session ended gracefully rather than left in a broken state. | | `unified` | Vercel AI SDK (`ai` package) | Covers OpenRouter, local models, any provider with an `ai`-compatible adapter | Model selection is driven by `modelRegistry.ts`. The user picks a model @@ -201,6 +201,16 @@ owner through a cascade: explicit tool argument, session identity field, and finally an implicit fallback for standalone chat sessions (no mission/run/step context) using the caller ID. +## Diagnostic Logging + +Chat runtime startup emits structured diagnostic logs that include MCP +launch mode, resolved entry path, socket path, packaged-build status, +and the Claude executable path. Codex runtime startup logs include the +working directory and shell environment. Claude V2 prewarm failures +include MCP launch details for troubleshooting. These diagnostics make +it possible to isolate packaging or PATH-related failures without +attaching a debugger. + ## Identity Session Filtering CTO and worker identity sessions (those with an `identityKey`) are From 734f154e488289327f1a25671d0cd2fff23a8e67 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:09:27 -0400 Subject: [PATCH 3/3] Fix valid PR #83 review comments [skip ci] Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/runtimeBinaryPermissions.cjs | 10 +++++ apps/desktop/src/main/adeMcpProxy.ts | 2 - .../main/services/chat/agentChatService.ts | 37 +++++++++++++------ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/desktop/scripts/runtimeBinaryPermissions.cjs b/apps/desktop/scripts/runtimeBinaryPermissions.cjs index c7022a87..3490fc20 100644 --- a/apps/desktop/scripts/runtimeBinaryPermissions.cjs +++ b/apps/desktop/scripts/runtimeBinaryPermissions.cjs @@ -52,6 +52,16 @@ function normalizeFileSet(filePaths, label) { function collectDesktopRuntimeExecutableCandidates(rootPath) { const candidates = []; + for (const buildDir of [ + path.join(rootPath, "node_modules", "node-pty", "build", "Release"), + path.join(rootPath, "node_modules", "node-pty", "build", "Debug"), + ]) { + candidates.push({ + filePath: path.join(buildDir, "spawn-helper"), + label: "node-pty spawn helper", + }); + } + for (const prebuildDir of listDirectories(path.join(rootPath, "node_modules", "node-pty", "prebuilds"))) { candidates.push({ filePath: path.join(prebuildDir, "spawn-helper"), diff --git a/apps/desktop/src/main/adeMcpProxy.ts b/apps/desktop/src/main/adeMcpProxy.ts index 4b679f81..069e2428 100644 --- a/apps/desktop/src/main/adeMcpProxy.ts +++ b/apps/desktop/src/main/adeMcpProxy.ts @@ -61,7 +61,6 @@ function relayProxyInputWithIdentity(socket: net.Socket): void { process.stdin.pipe(socket); process.stdin.on("end", () => { socket.end(); - process.exit(0); }); return; } @@ -92,7 +91,6 @@ function relayProxyInputWithIdentity(socket: net.Socket): void { pending = Buffer.alloc(0); } socket.end(); - process.exit(0); }); } diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c03f46ea..96e50e9c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -4692,16 +4692,21 @@ export function createAgentChatService(args: { }; const startCodexRuntime = async (managed: ManagedChatSession): Promise => { + let adeMcpLaunch: ReturnType | undefined; + try { + adeMcpLaunch = summarizeAdeMcpLaunch({ + defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", + ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey), + computerUsePolicy: managed.session.computerUse, + }); + } catch { /* best-effort diagnostic — must not block Codex startup */ } + 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, - }), + ...(adeMcpLaunch ? { adeMcpLaunch } : {}), }); const proc = spawn("codex", ["app-server"], { cwd: managed.laneWorktreePath, @@ -4841,7 +4846,7 @@ export function createAgentChatService(args: { void finishSession(managed, "failed", { exitCode: null, summary: message, - }); + }).catch(() => {}); }); proc.on("exit", (code, signal) => { @@ -5185,15 +5190,23 @@ export function createAgentChatService(args: { error instanceof Error ? error.message : String(error), ); } - 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({ + let diagClaudePath: string | undefined; + let diagMcpLaunch: ReturnType | undefined; + try { + diagClaudePath = runtime.v2Session ? undefined : buildClaudeV2SessionOpts(managed, runtime).pathToClaudeCodeExecutable; + } catch { /* best-effort diagnostic */ } + try { + diagMcpLaunch = summarizeAdeMcpLaunch({ defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey), computerUsePolicy: managed.session.computerUse, - }), + }); + } catch { /* best-effort diagnostic */ } + logger.warn("agent_chat.claude_v2_prewarm_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + claudeExecutablePath: diagClaudePath, + ...(diagMcpLaunch ? { adeMcpLaunch: diagMcpLaunch } : {}), }); try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null;