diff --git a/electron.vite.config.1779130085645.mjs b/electron.vite.config.1779130085645.mjs new file mode 100644 index 00000000..98f673fd --- /dev/null +++ b/electron.vite.config.1779130085645.mjs @@ -0,0 +1,166 @@ +// electron.vite.config.ts +import { defineConfig } from "electron-vite"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; +import react from "@vitejs/plugin-react"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team"; +var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8")); +var prodDeps = Object.keys(pkg.dependencies || {}); +var runtimeExternalDeps = /* @__PURE__ */ new Set([ + "node-pty", + "agent-teams-controller", + "fastify", + "@fastify/cors", + "@fastify/static" +]); +var bundledDeps = prodDeps.filter((d) => !runtimeExternalDeps.has(d)); +function nativeModuleStub() { + const STUB_ID = "\0native-stub"; + const NODE_MODULE_RE = /\.node(?:\?.*)?$/; + return { + name: "native-module-stub", + enforce: "pre", + resolveId(source) { + if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`; + return null; + }, + load(id) { + if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return "export default {}"; + return null; + } + }; +} +var sentrySourceMapTargets = { + main: { + assets: ["./dist-electron/main/**/*.{js,cjs,mjs,map}"], + filesToDeleteAfterUpload: ["./dist-electron/main/**/*.map"] + }, + renderer: { + assets: ["./out/renderer/**/*.{js,cjs,mjs,map}"], + filesToDeleteAfterUpload: ["./out/renderer/**/*.map"] + } +}; +function createSentryPlugins(target) { + if (!process.env.SENTRY_AUTH_TOKEN) return []; + return [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG ?? "quant-jump-pro", + project: process.env.SENTRY_PROJECT ?? "electron", + authToken: process.env.SENTRY_AUTH_TOKEN, + telemetry: false, + release: { name: `agent-teams-ai@${pkg.version}` }, + sourcemaps: sentrySourceMapTargets[target] + }) + ]; +} +var electron_vite_config_default = defineConfig({ + main: { + plugins: [ + nativeModuleStub(), + ...createSentryPlugins("main") + ], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + // Inject DSN at compile time - process.env.SENTRY_DSN is NOT available + // at runtime in packaged Electron apps (only during CI build). + "process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "") + }, + resolve: { + alias: { + "@features": resolve(__electron_vite_injected_dirname, "src/features"), + "@main": resolve(__electron_vite_injected_dirname, "src/main"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@preload": resolve(__electron_vite_injected_dirname, "src/preload") + } + }, + build: { + externalizeDeps: { + exclude: bundledDeps + }, + commonjsOptions: { + strictRequires: [/node_modules\/.*ssh2\//] + }, + sourcemap: "hidden", + outDir: "dist-electron/main", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"), + "team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts"), + "task-change-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/task-change-worker.ts"), + "team-data-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-data-worker.ts") + }, + output: { + // CJS format so bundled deps can use __dirname/require. + // Use .cjs extension since package.json has "type": "module". + format: "cjs", + entryFileNames: "[name].cjs", + // Set UV_THREADPOOL_SIZE before any module code runs. + // Must be in the banner because ESM→CJS hoists imports above top-level code. + // On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher; + // with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock. + banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}` + } + } + } + }, + preload: { + resolve: { + alias: { + "@features": resolve(__electron_vite_injected_dirname, "src/features"), + "@preload": resolve(__electron_vite_injected_dirname, "src/preload"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main") + } + }, + build: { + outDir: "dist-electron/preload", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts") + }, + output: { + format: "cjs", + entryFileNames: "[name].js" + } + } + } + }, + renderer: { + cacheDir: resolve(__electron_vite_injected_dirname, "node_modules/.vite/electron-renderer"), + optimizeDeps: { + include: ["@codemirror/language-data"], + exclude: ["@claude-teams/agent-graph"] + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + // Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time) + "import.meta.env.VITE_SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "") + }, + resolve: { + alias: { + "@features": resolve(__electron_vite_injected_dirname, "src/features"), + "@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main"), + "@radix-ui/react-compose-refs": resolve( + __electron_vite_injected_dirname, + "src/renderer/vendor/radixComposeRefs.ts" + ), + "@claude-teams/agent-graph": resolve(__electron_vite_injected_dirname, "packages/agent-graph/src/index.ts") + } + }, + plugins: [react(), ...createSentryPlugins("renderer")], + build: { + sourcemap: "hidden", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html") + } + } + } + } +}); +export { + electron_vite_config_default as default +}; diff --git a/src/main/services/runtime/buildRuntimeBaseEnv.ts b/src/main/services/runtime/buildRuntimeBaseEnv.ts index 7da7e0e4..d455592a 100644 --- a/src/main/services/runtime/buildRuntimeBaseEnv.ts +++ b/src/main/services/runtime/buildRuntimeBaseEnv.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getShellPreferredHome } from '@main/utils/shellEnv'; @@ -21,6 +23,7 @@ export interface BuildRuntimeBaseEnvOptions { providerBackendId?: string | null; shellEnv?: NodeJS.ProcessEnv | null; env?: NodeJS.ProcessEnv; + mergePathFallbacks?: boolean; } function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined { @@ -32,18 +35,43 @@ function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): str return undefined; } +function mergePathValues(...values: (string | null | undefined)[]): string | undefined { + const seen = new Set(); + const merged: string[] = []; + for (const value of values) { + for (const entry of value?.split(path.delimiter) ?? []) { + const normalized = entry.trim(); + if (normalized && !seen.has(normalized)) { + seen.add(normalized); + merged.push(normalized); + } + } + } + return merged.length > 0 ? merged.join(path.delimiter) : undefined; +} + export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): { env: NodeJS.ProcessEnv; resolvedProviderId: CliProviderId | null; } { const shellEnv = options.shellEnv ?? {}; + const enrichedEnv = buildEnrichedEnv(options.binaryPath); + const mergedPath = options.mergePathFallbacks + ? mergePathValues(options.env?.PATH, shellEnv.PATH, enrichedEnv.PATH, process.env.PATH) + : undefined; const env = { - ...buildEnrichedEnv(options.binaryPath), + ...enrichedEnv, ...shellEnv, }; + if (mergedPath) { + env.PATH = mergedPath; + } applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); Object.assign(env, options.env ?? {}); + if (mergedPath) { + env.PATH = mergedPath; + } applyAgentTeamsIdentityEnv(env); const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env); if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) { diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index 656cdaa1..26b2eee4 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -43,6 +43,7 @@ export async function buildProviderAwareCliEnv( providerBackendId: options.providerBackendId, shellEnv, env: options.env, + mergePathFallbacks: true, }); if (!resolvedProviderId || resolvedProviderId === 'opencode') { const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath(); diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 949bed25..a57e93d4 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,9 +1,11 @@ import { execCli } from '@main/utils/childProcess'; +import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath, getMcpConfigsBasePath, getMcpServerBasePath, } from '@main/utils/pathDecoder'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; @@ -30,6 +32,7 @@ const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const logger = createLogger('Service:TeamMcpConfigBuilder'); const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; +const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; /** * Stale configs older than this are removed on startup (best-effort). * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved @@ -175,32 +178,113 @@ function emitProgress( options?.onProgress?.({ phase, message }); } +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function looksLikeNodeBinaryPath(candidate: string | undefined): candidate is string { + if (!candidate?.trim()) { + return false; + } + return /^node(?:-\d+)?(?:\.exe)?$/i.test(path.basename(candidate.trim())); +} + +function getNodeRuntimeCommandCandidates(): string[] { + const candidates = [ + process.env.NODE_BINARY, + process.env.npm_node_execpath, + 'node', + looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined, + ]; + const seen = new Set(); + return candidates.flatMap((candidate) => { + const normalized = candidate?.trim(); + if (!normalized || seen.has(normalized)) { + return []; + } + seen.add(normalized); + return [normalized]; + }); +} + +function mergePathValues(...values: (string | undefined)[]): string | undefined { + const seen = new Set(); + const merged: string[] = []; + for (const value of values) { + if (!value) { + continue; + } + for (const segment of value.split(path.delimiter)) { + if (!segment || seen.has(segment)) { + continue; + } + seen.add(segment); + merged.push(segment); + } + } + return merged.length > 0 ? merged.join(path.delimiter) : undefined; +} + +function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + ...shellEnv, + }; + const mergedPath = mergePathValues(shellEnv.PATH, buildMergedCliPath(), process.env.PATH); + if (mergedPath) { + env.PATH = mergedPath; + } + return env; +} + /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. - * Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency). + * Uses the user's shell/enriched PATH so packaged GUI launches do not depend + * on the minimal Finder/Dock PATH. */ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { if (_resolvedNodePath) return _resolvedNodePath; + let shellEnv: NodeJS.ProcessEnv = {}; try { emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); - const { stdout } = await execCli('node', ['-e', 'process.stdout.write(process.execPath)'], { - encoding: 'utf-8', - timeout: 5000, + shellEnv = await resolveInteractiveShellEnv({ + onProgress: options?.onProgress + ? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message) + : undefined, }); - const resolved = stdout.trim(); - if (resolved) { + } catch (error) { + logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`); + } + + const env = buildNodeResolveEnv(shellEnv); + let lastError: unknown = null; + for (const command of getNodeRuntimeCommandCandidates()) { + try { + const { stdout } = await execCli(command, ['-e', 'process.stdout.write(process.execPath)'], { + encoding: 'utf-8', + timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS, + env, + }); + const resolved = stdout.trim(); + if (!resolved) { + throw new Error(`${command} did not report process.execPath`); + } _resolvedNodePath = resolved; emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); return _resolvedNodePath; + } catch (error) { + lastError = error; } - } catch { - // node not found or timed out — use bare 'node' and let the OS resolve it } - _resolvedNodePath = 'node'; - emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...'); - return _resolvedNodePath; + + emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.'); + throw new Error( + `Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${ + lastError ? stringifyError(lastError) : 'no Node.js candidates were available' + }` + ); } /** diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d26dfcbf..a72bfb0b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -8130,6 +8130,18 @@ export class TeamProvisioningService { return typeof deliveredUserMessageId === 'string' && deliveredUserMessageId.trim().length > 0; } + private hasOpenCodeAcceptedRuntimePrompt( + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null + ): boolean { + return Boolean( + ledgerRecord?.acceptedAt || + ledgerRecord?.runtimePromptMessageId?.trim() || + ledgerRecord?.lastRuntimePromptMessageId?.trim() || + ledgerRecord?.deliveredUserMessageId?.trim() || + (ledgerRecord?.runtimePromptMessageIds ?? []).some((messageId) => messageId.trim()) + ); + } + private isOpenCodeDeliveryRetryablePendingResponse(input: { ledgerRecord: OpenCodePromptDeliveryLedgerRecord; visibleReply?: OpenCodeVisibleReplyProof | null; @@ -8138,6 +8150,12 @@ export class TeamProvisioningService { if (input.readAllowed) { return false; } + if ( + input.ledgerRecord.responseState === 'session_stale' && + this.hasOpenCodeAcceptedRuntimePrompt(input.ledgerRecord) + ) { + return false; + } if (isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState)) { return true; } @@ -9405,6 +9423,54 @@ export class TeamProvisioningService { const now = nowIso(); const sessionRefreshRetry = input.retry && this.isOpenCodeSessionRefreshRetryRecord(input.ledgerRecord, input.reason); + const acceptedPromptSessionStaleObservation = + !input.retry && + input.ledgerRecord.responseState === 'session_stale' && + this.hasOpenCodeAcceptedRuntimePrompt(input.ledgerRecord); + if (acceptedPromptSessionStaleObservation) { + const maxSessionRefreshAttempts = + input.ledgerRecord.maxSessionRefreshAttempts ?? + OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS; + if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) { + return await input.ledger.markFailedTerminal({ + id: input.ledgerRecord.id, + reason: 'opencode_session_stale_observe_loop_after_accepted_prompt', + diagnostics: [ + input.reason, + `OpenCode session stayed stale while observing an accepted prompt after ${maxSessionRefreshAttempts} attempt(s).`, + ], + failedAt: now, + }); + } + const delayMs = OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS; + const nextAttemptAt = new Date(Date.now() + delayMs).toISOString(); + const ledgerRecord = await input.ledger.markSessionStaleObservationScheduled({ + id: input.ledgerRecord.id, + nextAttemptAt, + reason: input.reason, + scheduledAt: now, + maxSessionRefreshAttempts, + diagnostics: ['opencode_session_stale_observe_scheduled_after_accepted_prompt'], + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_response_observed', + ledgerRecord, + { + retry: false, + reason: input.reason, + observeOnlyAfterAcceptedPrompt: true, + sessionRefreshAttempts: ledgerRecord.sessionRefreshAttempts ?? 0, + maxSessionRefreshAttempts, + } + ); + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName: input.teamName, + memberName: input.memberName, + messageId: input.ledgerRecord.inboxMessageId, + delayMs, + }); + return ledgerRecord; + } if (sessionRefreshRetry) { const maxSessionRefreshAttempts = input.ledgerRecord.maxSessionRefreshAttempts ?? @@ -10659,6 +10725,7 @@ export class TeamProvisioningService { const retryShouldRefreshSessionBeforeObserve = retryDueBeforeObserve && ledgerRecord.status === 'retry_scheduled' && + !this.hasOpenCodeAcceptedRuntimePrompt(ledgerRecord) && this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason); if ( ledgerRecord.status !== 'pending' && @@ -10825,6 +10892,23 @@ export class TeamProvisioningService { retry: retryable, reason: pendingReason, }); + if (ledgerRecord.status === 'failed_terminal') { + return { + delivered: false, + accepted: true, + responsePending: false, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal', + diagnostics: ledgerRecord.diagnostics.length + ? ledgerRecord.diagnostics + : [ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal'], + }; + } return { delivered: true, accepted: true, @@ -10865,6 +10949,7 @@ export class TeamProvisioningService { if ( !forceOpenCodeSessionRefreshReason && ledgerRecord?.status === 'retry_scheduled' && + !this.hasOpenCodeAcceptedRuntimePrompt(ledgerRecord) && isOpenCodePromptDeliveryAttemptDue(ledgerRecord) && this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason) ) { @@ -16505,9 +16590,10 @@ export class TeamProvisioningService { ...(input.configuredMember.agentType ? ['--agent-type', input.configuredMember.agentType] : []), + '--setting-sources', + 'user,project,local', '--mcp-config', mcpConfigPath, - '--strict-mcp-config', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS, ...(input.run.request.skipPermissions !== false @@ -16686,9 +16772,10 @@ export class TeamProvisioningService { ...(input.configuredMember.agentType ? ['--agent-type', input.configuredMember.agentType] : []), + '--setting-sources', + 'user,project,local', '--mcp-config', mcpConfigPath, - '--strict-mcp-config', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS, ...(input.run.request.skipPermissions !== false diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 6c15602e..d0433988 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -583,6 +583,38 @@ export class OpenCodePromptDeliveryLedgerStore { }); } + async markSessionStaleObservationScheduled(input: { + id: string; + nextAttemptAt: string; + reason: string; + scheduledAt: string; + maxSessionRefreshAttempts?: number; + diagnostics?: string[]; + }): Promise { + return await this.updateExisting(input.id, (record) => { + const maxSessionRefreshAttempts = + record.maxSessionRefreshAttempts ?? + input.maxSessionRefreshAttempts ?? + OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS; + const sessionRefreshAttempts = (record.sessionRefreshAttempts ?? 0) + 1; + return { + ...record, + status: 'accepted', + responseState: 'session_stale', + nextAttemptAt: input.nextAttemptAt, + sessionRefreshAttempts, + maxSessionRefreshAttempts, + lastSessionRefreshReason: input.reason, + lastReason: input.reason, + diagnostics: mergeDiagnostics(record.diagnostics, [ + input.reason, + ...(input.diagnostics ?? []), + ]), + updatedAt: input.scheduledAt, + }; + }); + } + async markRetryAttempted(input: { id: string; attemptedAt: string; diff --git a/src/main/utils/cliPathMerge.ts b/src/main/utils/cliPathMerge.ts index e0c36118..5705510a 100644 --- a/src/main/utils/cliPathMerge.ts +++ b/src/main/utils/cliPathMerge.ts @@ -37,11 +37,9 @@ export function buildMergedCliPath(binaryPath?: string | null): string { const cachedEnv = getCachedShellEnv(); if (cachedEnv?.PATH) { extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean)); - extraDirs.push(vendorBinDir); - if (process.platform !== 'win32') { - extraDirs.push(pathPosix.join(home, '.bun', 'bin')); - } - } else if (process.platform === 'win32') { + } + + if (process.platform === 'win32') { extraDirs.push( vendorBinDir, pathWin32.join(home, 'AppData', 'Roaming', 'npm'), diff --git a/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts b/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts index 50c0fe1b..36f15cb6 100644 --- a/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts +++ b/test/main/services/runtime/OpenCodeRuntimePreflight.integration.test.ts @@ -66,6 +66,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => { let originalPath: string | undefined; let originalShell: string | undefined; let originalFakeOpenCodeBinDir: string | undefined; + let originalFakeNodePath: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-prod-preflight-')); @@ -75,6 +76,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => { originalPath = process.env.PATH; originalShell = process.env.SHELL; originalFakeOpenCodeBinDir = process.env.FAKE_OPENCODE_BIN_DIR; + originalFakeNodePath = process.env.FAKE_NODE_PATH; process.env.PATH = ''; vi.clearAllMocks(); @@ -99,6 +101,11 @@ describePosix('OpenCode packaged-runtime preflight integration', () => { } else { process.env.FAKE_OPENCODE_BIN_DIR = originalFakeOpenCodeBinDir; } + if (originalFakeNodePath === undefined) { + delete process.env.FAKE_NODE_PATH; + } else { + process.env.FAKE_NODE_PATH = originalFakeNodePath; + } if (tempDir) { await rm(tempDir, { recursive: true, force: true }); @@ -127,6 +134,26 @@ describePosix('OpenCode packaged-runtime preflight integration', () => { return { binDir, binaryPath }; } + async function createFakeNodeBinary(binDir: string): Promise { + const binaryPath = path.join(binDir, 'node'); + process.env.FAKE_NODE_PATH = binaryPath; + await writeFile( + binaryPath, + [ + '#!/bin/sh', + 'if [ "$1" = "-e" ]; then', + ' printf "%s" "$FAKE_NODE_PATH"', + ' exit 0', + 'fi', + 'echo "unexpected node args: $*" >&2', + 'exit 2', + ].join('\n'), + 'utf8' + ); + await chmod(binaryPath, 0o755); + return binaryPath; + } + async function createFakeInteractiveShell(binDir: string): Promise { const shellPath = path.join(tempDir!, 'fake-login-shell'); process.env.FAKE_OPENCODE_BIN_DIR = binDir; @@ -196,4 +223,26 @@ describePosix('OpenCode packaged-runtime preflight integration', () => { }); expect(version.stdout.trim()).toBe('opencode 9.9.9'); }); + + it('resolves the Agent Teams MCP command to shell Node when GUI PATH is empty', async () => { + const { binDir } = await createFakeOpenCodeBinary(); + const nodePath = await createFakeNodeBinary(binDir); + process.env.SHELL = await createFakeInteractiveShell(binDir); + + const providerEnv = await buildProviderAwareCliEnv({ + providerId: 'opencode', + connectionMode: 'augment', + shellEnv: {}, + env: { + PATH: '', + }, + }); + + expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe(nodePath); + expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).not.toBe('node'); + expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBeTruthy(); + expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toContain( + providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY ?? '' + ); + }); }); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 67ffc330..4f75d1ce 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -140,6 +140,41 @@ describe('buildProviderAwareCliEnv', () => { expect(result.providerArgs).toEqual([]); }); + it('keeps enriched PATH entries when a provider shell env has a narrower PATH', async () => { + buildEnrichedEnvMock.mockReturnValue({ + PATH: ['/mock/runtime/bin', '/usr/local/bin', '/usr/bin'].join(path.delimiter), + }); + + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + const result = await buildProviderAwareCliEnv({ + binaryPath: '/mock/claude', + providerId: 'codex', + shellEnv: { + PATH: ['/usr/bin', '/bin'].join(path.delimiter), + }, + }); + + expect(result.env.PATH?.split(path.delimiter).slice(0, 4)).toEqual([ + '/usr/bin', + '/bin', + '/mock/runtime/bin', + '/usr/local/bin', + ]); + const appliedEnv = applyConfiguredConnectionEnvMock.mock.calls[0]?.[0] as NodeJS.ProcessEnv; + expect(appliedEnv.PATH?.split(path.delimiter).slice(0, 4)).toEqual([ + '/usr/bin', + '/bin', + '/mock/runtime/bin', + '/usr/local/bin', + ]); + expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.any(Object), + 'codex', + undefined + ); + }); + it('passes metadata-only stored API key access through provider env building', async () => { const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index 7efb904c..f58dfc39 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -770,6 +770,57 @@ describe('OpenCodePromptDeliveryLedger', () => { ).toBe(true); }); + it('tracks observe-only stale sessions without scheduling another prompt send', async () => { + const store = createStore(); + const record = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-session-stale-observe-only', + inboxTimestamp: '2026-04-25T09:59:00.000Z', + source: 'watcher', + replyRecipient: 'user', + payloadHash: 'sha256:session-stale-observe-only', + now: '2026-04-25T10:00:00.000Z', + }); + const accepted = await store.applyDeliveryResult({ + id: record.id, + accepted: true, + attempted: true, + sessionId: 'oc-session-1', + runtimePromptMessageId: 'msg_prompt_1', + deliveryAttemptId: 'attempt-1', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'msg_prompt_1', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + now: '2026-04-25T10:00:01.000Z', + }); + + const scheduled = await store.markSessionStaleObservationScheduled({ + id: accepted.id, + nextAttemptAt: '2026-04-25T10:00:10.000Z', + reason: 'resolved_behavior_changed:old->new', + scheduledAt: '2026-04-25T10:00:06.000Z', + diagnostics: ['opencode_session_stale_observe_scheduled_after_accepted_prompt'], + }); + + expect(scheduled.status).toBe('accepted'); + expect(scheduled.attempts).toBe(1); + expect(scheduled.sessionRefreshAttempts).toBe(1); + expect(scheduled.runtimePromptMessageIds).toEqual(['msg_prompt_1']); + expect(buildOpenCodePromptDeliveryAttemptId(scheduled)).toBe( + `${record.id}:2:${record.payloadHash.slice(0, 12)}:refresh1` + ); + }); + it('does not treat session_stale with action-required diagnostics as a refresh retry', async () => { const store = createStore(); const record = await store.ensurePending({ diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 6ba53f95..9d4f96a8 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -4,12 +4,28 @@ import Module from 'module'; import * as os from 'os'; import * as path from 'path'; +type ExecCliMock = ( + binaryPath: string | null, + args: string[], + options?: { + encoding?: BufferEncoding; + timeout?: number; + env?: NodeJS.ProcessEnv | Record; + } +) => Promise<{ stdout: string; stderr: string }>; + +type ResolveInteractiveShellEnvMock = (options?: unknown) => Promise; + const hoisted = vi.hoisted(() => ({ electronState: { isPackaged: false, version: '9.9.9-test', }, - execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })), + execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })), + cachedShellEnv: null as NodeJS.ProcessEnv | null, + resolveInteractiveShellEnvMock: vi.fn( + async () => ({} as NodeJS.ProcessEnv) + ), })); let mockHomeDir = ''; @@ -33,6 +49,15 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }; }); +vi.mock('@main/utils/shellEnv', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedShellEnv: () => hoisted.cachedShellEnv, + resolveInteractiveShellEnv: hoisted.resolveInteractiveShellEnvMock, + }; +}); + import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { TeamMcpConfigBuilder, @@ -178,6 +203,10 @@ describe('TeamMcpConfigBuilder', () => { setPackagedMode(false); setResourcesPath(undefined); hoisted.execCliMock.mockClear(); + hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' }); + hoisted.cachedShellEnv = null; + hoisted.resolveInteractiveShellEnvMock.mockClear(); + hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({}); }); afterEach(() => { @@ -282,7 +311,72 @@ describe('TeamMcpConfigBuilder', () => { expect(hoisted.execCliMock).toHaveBeenCalledWith( 'node', ['-e', 'process.stdout.write(process.execPath)'], - expect.objectContaining({ encoding: 'utf-8', timeout: 5000 }) + expect.objectContaining({ + encoding: 'utf-8', + timeout: 5000, + env: expect.objectContaining({ PATH: expect.any(String) }), + }) + ); + }); + + it('resolves packaged MCP Node through shell PATH instead of writing a bare node command', async () => { + setPackagedMode(true, '2.0.0'); + const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); + createdDirs.push(resourcesDir); + createPackagedServerBundle(resourcesDir, '// packaged server'); + setResourcesPath(resourcesDir); + hoisted.cachedShellEnv = { + PATH: ['/mock-shell-node-bin', '/usr/bin'].join(path.delimiter), + HOME: '/Users/tester', + }; + hoisted.resolveInteractiveShellEnvMock.mockResolvedValue(hoisted.cachedShellEnv); + hoisted.execCliMock.mockImplementationOnce(async (command, _args, options) => { + expect(command).toBe('node'); + const env = options?.env as NodeJS.ProcessEnv | undefined; + expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin'); + return { stdout: '/mock-shell-node-bin/node', stderr: '' }; + }); + + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node'); + expect(readGeneratedServer(configPath)?.command).not.toBe('node'); + expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('prefers an explicit NODE_BINARY over PATH-based node lookup', async () => { + mockBuiltWorkspaceEntryAvailable(); + const previousNodeBinary = process.env.NODE_BINARY; + process.env.NODE_BINARY = '/explicit/node'; + hoisted.execCliMock.mockImplementationOnce(async (command) => { + expect(command).toBe('/explicit/node'); + return { stdout: '/explicit/node', stderr: '' }; + }); + + try { + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node'); + } finally { + if (previousNodeBinary === undefined) { + delete process.env.NODE_BINARY; + } else { + process.env.NODE_BINARY = previousNodeBinary; + } + } + }); + + it('fails fast when Node cannot be resolved instead of emitting a broken bare node command', async () => { + mockBuiltWorkspaceEntryAvailable(); + hoisted.execCliMock.mockRejectedValue(new Error('spawn node ENOENT')); + const builder = new TeamMcpConfigBuilder(); + + await expect(builder.writeConfigFile()).rejects.toThrow( + 'Node.js runtime for Agent Teams MCP was not found' ); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b3ef79e1..2a4fbbd5 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -5354,7 +5354,9 @@ describe('TeamProvisioningService', () => { expect(command).toContain("'--agent-id' 'bob@forge-labs-10'"); expect(command).toContain("'--team-name' 'forge-labs-10'"); expect(command).toContain("'--parent-session-id' 'lead-session-1'"); + expect(command).toContain("'--setting-sources' 'user,project,local'"); expect(command).toContain("'--mcp-config' '/mock/mcp-config.json'"); + expect(command).not.toContain('--strict-mcp-config'); expect(command).toContain("'--model' 'gpt-5.4'"); expect(command).toContain("'--effort' 'high'"); expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__'); @@ -7721,7 +7723,9 @@ describe('TeamProvisioningService', () => { ); }); - it('retries due stale OpenCode sessions instead of observing forever', async () => { + it('observes due stale OpenCode sessions without duplicating accepted prompts', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-25T10:00:00.000Z')); const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ ok: true, @@ -7859,7 +7863,7 @@ describe('TeamProvisioningService', () => { delivered: true, responsePending: true, responseState: 'session_stale', - ledgerStatus: 'retry_scheduled', + ledgerStatus: 'accepted', }); expect(observeMessageDelivery).toHaveBeenCalledTimes(1); @@ -7874,6 +7878,7 @@ describe('TeamProvisioningService', () => { const scheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { data: Array<{ + status?: string; nextAttemptAt: string | null; diagnostics?: string[]; lastReason?: string | null; @@ -7888,6 +7893,7 @@ describe('TeamProvisioningService', () => { maxAttempts: 3, sessionRefreshAttempts: 1, }); + scheduledEnvelope.data[0].status = 'retry_scheduled'; scheduledEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z'; scheduledEnvelope.data[0].lastReason = 'resolved_behavior_changed:old->new'; scheduledEnvelope.data[0].lastSessionRefreshReason = 'resolved_behavior_changed:old->new'; @@ -7909,19 +7915,26 @@ describe('TeamProvisioningService', () => { ).resolves.toMatchObject({ delivered: true, responsePending: true, - responseState: 'pending', + responseState: 'session_stale', + ledgerStatus: 'accepted', }); - expect(sendMessageToMember).toHaveBeenCalledTimes(2); - expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({ - runId: 'opencode-run-bob', - teamName: 'team-a', - laneId: 'secondary:opencode:bob', - memberName: 'bob', - cwd: '/repo', - messageId: 'msg-stale-session', - deliveryAttemptId: expect.stringContaining(':refresh1'), - forceSessionRefreshReason: 'resolved_behavior_changed:old->new', + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledTimes(2); + expect(sendMessageToMember).not.toHaveBeenCalledWith( + expect.objectContaining({ + deliveryAttemptId: expect.stringContaining(':refresh'), + forceSessionRefreshReason: 'resolved_behavior_changed:old->new', + }) + ); + const rescheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array>; + }; + expect(rescheduledEnvelope.data[0]).toMatchObject({ + status: 'accepted', + responseState: 'session_stale', + sessionRefreshAttempts: 2, + nextAttemptAt: '2026-04-25T10:00:15.000Z', }); }); @@ -8590,12 +8603,23 @@ describe('TeamProvisioningService', () => { }); }); - it('stops repeated OpenCode session refresh loops at the refresh cap', async () => { + it.each([ + { + label: 'resolved behavior changes', + staleReason: 'resolved_behavior_changed:old->new', + staleDiagnostics: ['OpenCode session reconcile skipped because the stored session is stale'], + }, + { + label: 'action-required reasons', + staleReason: 'permission denied', + staleDiagnostics: ['permission denied'], + }, + ])('bounds accepted OpenCode session-stale observations for $label', async (scenario) => { const svc = new TeamProvisioningService(); - const sendMessageToMember = vi.fn(async (input: Record) => ({ + const sendMessageToMember = vi.fn(async (deliveryInput: Record) => ({ ok: true, providerId: 'opencode', - memberName: String(input.memberName), + memberName: String(deliveryInput.memberName), sessionId: 'oc-session-bob', runtimePromptMessageId: 'msg_prompt_refresh_cap', prePromptCursor: 'cursor-refresh-cap', @@ -8612,10 +8636,10 @@ describe('TeamProvisioningService', () => { }, diagnostics: [], })); - const observeMessageDelivery = vi.fn(async (input: Record) => ({ + const observeMessageDelivery = vi.fn(async (deliveryInput: Record) => ({ ok: true, providerId: 'opencode', - memberName: String(input.memberName), + memberName: String(deliveryInput.memberName), sessionId: 'oc-session-bob', responseObservation: { state: 'session_stale', @@ -8626,9 +8650,9 @@ describe('TeamProvisioningService', () => { visibleReplyMessageId: null, visibleReplyCorrelation: null, latestAssistantPreview: null, - reason: 'resolved_behavior_changed:old->new', + reason: scenario.staleReason, }, - diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'], + diagnostics: scenario.staleDiagnostics, })); await configureOpenCodeBobDeliveryService({ svc, @@ -8663,7 +8687,7 @@ describe('TeamProvisioningService', () => { status: 'accepted', responseState: 'session_stale', nextAttemptAt: '2000-01-01T00:00:00.000Z', - lastReason: 'resolved_behavior_changed:old->new', + lastReason: scenario.staleReason, sessionRefreshAttempts: 5, maxSessionRefreshAttempts: 5, }); @@ -8683,7 +8707,7 @@ describe('TeamProvisioningService', () => { responsePending: false, responseState: 'session_stale', ledgerStatus: 'failed_terminal', - reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed', + reason: 'opencode_session_stale_observe_loop_after_accepted_prompt', }); expect(sendMessageToMember).toHaveBeenCalledTimes(1); @@ -8696,7 +8720,7 @@ describe('TeamProvisioningService', () => { attempts: 1, sessionRefreshAttempts: 5, maxSessionRefreshAttempts: 5, - lastReason: 'opencode_session_refresh_loop_after_resolved_behavior_changed', + lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt', }); }); @@ -14825,6 +14849,92 @@ describe('TeamProvisioningService', () => { expect(run.pendingMemberRestarts.has('forge')).toBe(true); }); + it('launches direct process teammate restarts with normal MCP settings inheritance', async () => { + const teamName = 'process-flags-team'; + const projectPath = path.join(tempProjectsBase, 'process-flags-project'); + fs.mkdirSync(projectPath, { recursive: true }); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + const child = Object.assign(new EventEmitter(), { + pid: 4567, + stdin: { on: vi.fn(), unref: vi.fn() }, + stdout: { pipe: vi.fn(), unref: vi.fn() }, + stderr: { pipe: vi.fn(), unref: vi.fn() }, + unref: vi.fn(), + }); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'), + } as any); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['forge'], + memberSpawnStatuses: new Map(), + }); + run.child = { pid: 111 }; + run.request = { providerId: 'codex', skipPermissions: true }; + run.detectedSessionId = 'lead-session-1'; + const configuredMember = { + name: 'forge', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + agentType: 'general-purpose', + }; + const config = { + name: 'Process Flags Team', + projectPath, + leadSessionId: 'lead-session-1', + members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember], + }; + + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test-openai-key' }, + authSource: 'openai_api_key', + providerArgs: [], + })); + (svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({ + fastModeArgs: [], + runtimeTurnSettledHookArgs: [], + providerArgs: [], + settingsArgs: [], + extraArgs: [], + })); + (svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({})); + (svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {}); + (svc as any).enqueueDirectRestartPrompt = vi.fn(); + (svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {}); + + await (svc as any).launchDirectProcessMemberRestart({ + run, + teamName, + displayName: 'Process Flags Team', + leadName: 'team-lead', + memberName: 'forge', + config, + configuredMember, + persistedRuntimeMembers: [], + }); + + child.emit('close', 0, null); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + expect(launchArgs).toEqual( + expect.arrayContaining([ + '--teammate-runtime', + 'headless', + '--setting-sources', + 'user,project,local', + '--mcp-config', + '/mock/mcp-config.json', + ]) + ); + expect(launchArgs).not.toContain('--strict-mcp-config'); + }); + it('rejects a second restart request while the first restart is still in flight', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ diff --git a/test/main/utils/cliPathMerge.test.ts b/test/main/utils/cliPathMerge.test.ts index 56a48ceb..385e82b7 100644 --- a/test/main/utils/cliPathMerge.test.ts +++ b/test/main/utils/cliPathMerge.test.ts @@ -93,7 +93,7 @@ describe('buildMergedCliPath', () => { expect(parts[parts.length - 1]).toBe('/usr/bin'); }); - it('when shell cache has PATH, uses that instead of static fallback dirs', () => { + it('when shell cache has PATH, keeps it first and still adds static fallback dirs', () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); mockGetCachedShellEnv.mockReturnValue({ PATH: '/opt/custom/bin:/bin' }); const p = buildMergedCliPath(null); @@ -101,8 +101,9 @@ describe('buildMergedCliPath', () => { expect(p).toContain('/bin'); expect(p).toContain('/home/testuser/.claude/local/node_modules/.bin'); expect(p).toContain('/home/testuser/.bun/bin'); + expect(p).toContain('/home/testuser/.local/bin'); + expect(p).toContain('/usr/local/bin'); expect(p).toContain('/usr/bin'); - expect(p).not.toContain('/home/testuser/.local/bin'); }); it('prepends binary directory when binaryPath is set', () => {