diff --git a/src/main/index.ts b/src/main/index.ts index 5bfd8922..e5bdb94b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2247,7 +2247,7 @@ async function shutdownServices(): Promise { 10_000 ); await runShutdownStep('Agent Teams MCP HTTP server cleanup', () => - agentTeamsMcpHttpServer.stop() + agentTeamsMcpHttpServer.stop({ preventRestart: true }) ); await runShutdownStep('tracked CLI subprocess cleanup', () => killTrackedCliProcesses('SIGKILL') diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 65daa752..ada20d67 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -94,7 +94,7 @@ import { TEAM_VALIDATE_CLI_ARGS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks'; +import { wrapAgentBlock } from '@shared/constants/agentBlocks'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; @@ -2786,27 +2786,27 @@ function buildMessageDeliveryText( 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', ]; hiddenBlocks.push( - [ - AGENT_BLOCK_OPEN, - `You received a direct message from ${senderDescriptor} via the UI.`, - ...replyInstructionLines, - `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, - 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', - ...(canUseAgentTeamsMessageSend - ? [ - 'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.', - ] - : []), - ...(isUserReplyRecipient - ? [ - 'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").', - 'Only after that first acknowledgement may you message the lead or another teammate.', - 'After you get the needed information, send the final answer back to "user".', - 'Do NOT stay silent while you go ask someone else.', - ] - : []), - AGENT_BLOCK_CLOSE, - ].join('\n') + wrapAgentBlock( + [ + `You received a direct message from ${senderDescriptor} via the UI.`, + ...replyInstructionLines, + `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, + 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', + ...(canUseAgentTeamsMessageSend + ? [ + 'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.', + ] + : []), + ...(isUserReplyRecipient + ? [ + 'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").', + 'Only after that first acknowledgement may you message the lead or another teammate.', + 'After you get the needed information, send the final answer back to "user".', + 'Do NOT stay silent while you go ask someone else.', + ] + : []), + ].join('\n') + ) ); } @@ -3062,10 +3062,12 @@ async function handleSendMessage( `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, ...(rosterContextBlock ? [rosterContextBlock] : []), ...(delegateAckBlock ? [delegateAckBlock] : []), - AGENT_BLOCK_OPEN, - `MessageId: ${preGeneratedMessageId}`, - `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, - AGENT_BLOCK_CLOSE, + wrapAgentBlock( + [ + `MessageId: ${preGeneratedMessageId}`, + `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, + ].join('\n') + ), ``, `Message from user:`, buildMessageDeliveryText(payload.text!, { diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts index bf9f01e7..b4c179f1 100644 --- a/src/main/services/team/AgentTeamsMcpHttpServer.ts +++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts @@ -8,7 +8,7 @@ import * as path from 'node:path'; import { type RuntimeProcessTableRow } from '@features/tmux-installer/main'; import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore'; import { atomicWriteAsync } from '@main/utils/atomicWrite'; -import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { killProcessTree, spawnCli, untrackCliProcess } from '@main/utils/childProcess'; import { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; import { createLogger } from '@shared/utils/logger'; @@ -332,11 +332,13 @@ function defaultSpawnProcess( args: string[], env: NodeJS.ProcessEnv ): ChildProcess { - return spawnCli(command, args, { + const child = spawnCli(command, args, { env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, }); + untrackCliProcess(child); + return child; } function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] { @@ -681,10 +683,12 @@ export class AgentTeamsMcpHttpServer { private readonly expectedStopChildren = new WeakSet(); private readonly ownerInstanceId = randomUUID(); private readonly startedAtMs = Date.now(); + private preventFutureStarts = false; constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} async ensureStarted(): Promise { + this.throwIfStartsPrevented(); if (this.startPromise) { return this.startPromise; } @@ -697,7 +701,10 @@ export class AgentTeamsMcpHttpServer { return this.startPromise; } - async stop(): Promise { + async stop(input: { preventRestart?: boolean } = {}): Promise { + if (input.preventRestart) { + this.preventFutureStarts = true; + } const child = this.child; const handle = this.handle; const releasePort = child ? (handle?.port ?? null) : null; @@ -730,6 +737,12 @@ export class AgentTeamsMcpHttpServer { return this.deps.statePath ?? buildStatePath(); } + private throwIfStartsPrevented(): void { + if (this.preventFutureStarts) { + throw new Error('Agent Teams MCP HTTP server startup is disabled during shutdown'); + } + } + private async reuseOrRestartExistingHandle( handle: AgentTeamsMcpHttpServerHandle ): Promise { @@ -1008,8 +1021,10 @@ export class AgentTeamsMcpHttpServer { reason?: string; } = {} ): Promise { + this.throwIfStartsPrevented(); const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; const launchSpec = await resolveLaunchSpec(); + this.throwIfStartsPrevented(); const expectedIdentity = buildExpectedIdentity(launchSpec, this.ownerInstanceId); const statePath = this.resolveStatePath(); const startUnlocked = async (effectiveStatePath: string | null, diagnostics: string[]) => @@ -1045,6 +1060,7 @@ export class AgentTeamsMcpHttpServer { const diagnostics = [...initialDiagnostics]; const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess; const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort; + this.throwIfStartsPrevented(); if (statePath) { const adopted = await this.tryAdoptStateHandle(statePath, expectedIdentity, diagnostics); @@ -1059,6 +1075,7 @@ export class AgentTeamsMcpHttpServer { statePath, diagnostics ); + this.throwIfStartsPrevented(); if (selectedTarget.kind === 'handle') { return selectedTarget.handle; } diff --git a/src/main/services/team/ClaudeDoctorProbe.ts b/src/main/services/team/ClaudeDoctorProbe.ts index 87827c07..772fa820 100644 --- a/src/main/services/team/ClaudeDoctorProbe.ts +++ b/src/main/services/team/ClaudeDoctorProbe.ts @@ -1,5 +1,5 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv'; -import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv'; +import { getShellPreferredHome } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; import type { IPty } from 'node-pty'; @@ -173,7 +173,6 @@ async function captureDoctorOutput(commandName: string): Promise } export async function getDoctorInvokedCandidates(commandName: string): Promise { - await resolveInteractiveShellEnv(); const output = await captureDoctorOutput(commandName); if (!output) { return []; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f3c445aa..27e3954c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2,12 +2,7 @@ import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/ import { yieldToEventLoop } from '@main/utils/asyncYield'; import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; -import { - AGENT_BLOCK_CLOSE, - AGENT_BLOCK_OPEN, - stripAgentBlocks, - wrapAgentBlock, -} from '@shared/constants/agentBlocks'; +import { stripAgentBlocks, wrapAgentBlock } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; @@ -2053,11 +2048,14 @@ export class TeamDataService { parts.push(`\nDetails:\n${task.description.trim()}`); } parts.push( - `\n${AGENT_BLOCK_OPEN}`, - `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, - `Update task status using the board MCP tools:`, - `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, - AGENT_BLOCK_CLOSE + '', + wrapAgentBlock( + [ + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, + `Update task status using the board MCP tools:`, + `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, + ].join('\n') + ) ); await this.sendMessage(teamName, { member: task.owner, diff --git a/src/main/services/team/TeamLaunchFailureArtifactPack.ts b/src/main/services/team/TeamLaunchFailureArtifactPack.ts index b8213729..86b8ffe2 100644 --- a/src/main/services/team/TeamLaunchFailureArtifactPack.ts +++ b/src/main/services/team/TeamLaunchFailureArtifactPack.ts @@ -120,7 +120,7 @@ export function redactLaunchFailureArtifactText(text: string): string { .replace(/sk-proj-[A-Za-z0-9_-]{20,}/g, '[REDACTED_OPENAI_API_KEY]') .replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]') .replace( - /\b(ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY|OPENROUTER_API_KEY|GEMINI_API_KEY)=([^\s"'`]+)/gi, + /\b(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|OPENAI_API_KEY|CODEX_API_KEY|OPENROUTER_API_KEY|GEMINI_API_KEY)\s*=\s*("[^"]*"|'[^']*'|[^\s"'`]+)/gi, '$1=[REDACTED]' ) // eslint-disable-next-line sonarjs/duplicates-in-character-class -- URL-safe token alphabet intentionally includes these literal characters. diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index b4fac5e4..683c5178 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -258,28 +258,9 @@ function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { 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 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...'); - shellEnv = await resolveInteractiveShellEnv({ - onProgress: options?.onProgress - ? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message) - : undefined, - }); - } catch (error) { - logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`); - } - - const env = buildNodeResolveEnv(shellEnv); +async function probeNodeRuntimePath( + env: NodeJS.ProcessEnv +): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> { let lastError: unknown = null; for (const command of getNodeRuntimeCommandCandidates()) { try { @@ -292,18 +273,55 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise { + if (_resolvedNodePath) return _resolvedNodePath; + + emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); + const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({})); + if (fastProbe.ok) { + _resolvedNodePath = fastProbe.path; + emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); + return _resolvedNodePath; + } + + let shellEnv: NodeJS.ProcessEnv = {}; + try { + shellEnv = await resolveInteractiveShellEnv({ + source: 'mcp-node-runtime', + onProgress: options?.onProgress + ? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message) + : undefined, + }); + } catch (error) { + logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`); + } + + const env = buildNodeResolveEnv(shellEnv); + const shellProbe = await probeNodeRuntimePath(env); + if (shellProbe.ok) { + _resolvedNodePath = shellProbe.path; + emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); + 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' + shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error) }` ); } diff --git a/src/main/services/team/actionModeInstructions.ts b/src/main/services/team/actionModeInstructions.ts index 589c8ca1..9eac1db2 100644 --- a/src/main/services/team/actionModeInstructions.ts +++ b/src/main/services/team/actionModeInstructions.ts @@ -1,4 +1,4 @@ -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; +import { wrapAgentBlock } from '@shared/constants/agentBlocks'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import type { AgentActionMode } from '@shared/types'; @@ -46,7 +46,7 @@ export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): st } const lines = ACTION_MODE_BLOCKS[mode]; - return `${AGENT_BLOCK_OPEN}\n${lines.join('\n')}\n${AGENT_BLOCK_CLOSE}`; + return wrapAgentBlock(lines.join('\n')); } export function isAgentActionMode(value: unknown): value is AgentActionMode { diff --git a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts index 533cdbbe..031c9b3f 100644 --- a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -75,7 +75,10 @@ function redactNativeBootstrapContextText(input: string): string { return input .replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]') .replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]') - .replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY)=\S+/g, '$1=[REDACTED]') + .replace( + /(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|OPENAI_API_KEY|CODEX_API_KEY)\s*=\s*("[^"]*"|'[^']*'|\S+)/g, + '$1=[REDACTED]' + ) .replace(/Bearer\s+[A-Z0-9._-]+/gi, 'Bearer [REDACTED]'); } diff --git a/src/main/services/team/progressPayload.ts b/src/main/services/team/progressPayload.ts index baf8898c..90167c82 100644 --- a/src/main/services/team/progressPayload.ts +++ b/src/main/services/team/progressPayload.ts @@ -26,6 +26,7 @@ const SECRET_FLAG_PATTERN = /(--(?:api[-_]key|token|password|secret|authorization|auth[-_]token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; const SECRET_ENV_ASSIGNMENT_PATTERN = /\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi; +const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*)(Bearer\s+)?("[^"]*"|'[^']*'|\S+)/gi; /** * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" @@ -76,6 +77,7 @@ function boundRedactedText( .replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]') .replace(SECRET_FLAG_PATTERN, '$1[redacted]') .replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]') + .replace(AUTH_HEADER_PATTERN, '$1$2[redacted]') .replace(/```/g, "'''"); return redacted.length > limit ? `${redacted.slice(0, limit - 3).trimEnd()}...` : redacted; } diff --git a/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts b/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts index c3d5bb15..930faeec 100644 --- a/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts +++ b/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts @@ -21,6 +21,7 @@ interface RuntimeDiagnosticRule { const SECRET_VALUE_PATTERNS = [ /\bsk-[A-Z0-9_-]{12,}\b/gi, /\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi, + /\b[A-Z0-9_]*(?:AUTH_TOKEN|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*[=:]\s*['"]?[^'"\s]+/gi, /\bauthorization:\s*bearer\s+[^'"\s]+/gi, ] as const; diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 87e31b05..9d444bc3 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -291,7 +291,7 @@ const CLI_ENV_DEFAULTS: Record = { const activeCliProcesses = new Set(); -function untrackCliProcess(child: ChildProcess | null): void { +export function untrackCliProcess(child: ChildProcess | null): void { if (child) { activeCliProcesses.delete(child); } diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts index a425beef..bf8987b5 100644 --- a/src/renderer/utils/memberLaunchDiagnostics.ts +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -76,7 +76,7 @@ const MAX_PERMISSION_REQUEST_IDS = 10; const SECRET_FLAG_PATTERN = /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; const SECRET_VALUE_PATTERN = - /\b(sk-[A-Za-z0-9._~+/=-]{12,}|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g; + /\b(sk-[A-Za-z0-9._~+/=-]{12,}|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}|[A-Z0-9_]*(?:API_KEY|AUTH_TOKEN|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*("[^"]*"|'[^']*'|\S+))\b/gi; const OPENCODE_SESSION_REFRESH_REASON_PATTERN = /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i; const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN = diff --git a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts index 52a4fd07..14539d16 100644 --- a/test/main/services/team/AgentTeamsMcpHttpServer.test.ts +++ b/test/main/services/team/AgentTeamsMcpHttpServer.test.ts @@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ killProcessTreeMock: vi.fn(), spawnCliMock: vi.fn(), + untrackCliProcessMock: vi.fn(), })); vi.mock('@main/utils/childProcess', async (importOriginal) => { @@ -21,6 +22,7 @@ vi.mock('@main/utils/childProcess', async (importOriginal) => { ...actual, killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args), spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args), + untrackCliProcess: (...args: unknown[]) => hoisted.untrackCliProcessMock(...args), }; }); @@ -114,6 +116,7 @@ describe('AgentTeamsMcpHttpServer', () => { beforeEach(() => { hoisted.killProcessTreeMock.mockReset(); hoisted.spawnCliMock.mockReset(); + hoisted.untrackCliProcessMock.mockReset(); }); it('starts the MCP server over HTTP with hidden app-owned process env', async () => { @@ -212,6 +215,7 @@ describe('AgentTeamsMcpHttpServer', () => { windowsHide: true, }) ); + expect(hoisted.untrackCliProcessMock).toHaveBeenCalledWith(child); }); it('coalesces concurrent starts', async () => { @@ -234,6 +238,48 @@ describe('AgentTeamsMcpHttpServer', () => { expect(spawnProcess).toHaveBeenCalledTimes(1); }); + it('does not start after shutdown has disabled future starts', async () => { + const spawnProcess = vi.fn(); + const server = new AgentTeamsMcpHttpServer({ + statePath: null, + resolveLaunchSpec: async () => ({ + command: 'node', + args: ['mcp-server/dist/index.js'], + }), + allocatePort: async () => 41026, + spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'], + waitForPort: vi.fn(async () => undefined), + }); + + await server.stop({ preventRestart: true }); + + await expect(server.ensureStarted()).rejects.toThrow('startup is disabled during shutdown'); + expect(spawnProcess).not.toHaveBeenCalled(); + }); + + it('cancels an in-flight start before spawn when shutdown disables future starts', async () => { + let resolveLaunchSpec!: (launchSpec: { command: string; args: string[] }) => void; + const launchSpecPromise = new Promise<{ command: string; args: string[] }>((resolve) => { + resolveLaunchSpec = resolve; + }); + const spawnProcess = vi.fn(); + const server = new AgentTeamsMcpHttpServer({ + statePath: null, + resolveLaunchSpec: async () => launchSpecPromise, + allocatePort: async () => 41027, + spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'], + waitForPort: vi.fn(async () => undefined), + }); + + const startPromise = server.ensureStarted(); + await new Promise((resolve) => setImmediate(resolve)); + await server.stop({ preventRestart: true }); + resolveLaunchSpec({ command: 'node', args: ['mcp-server/dist/index.js'] }); + + await expect(startPromise).rejects.toThrow('startup is disabled during shutdown'); + expect(spawnProcess).not.toHaveBeenCalled(); + }); + it('uses the persistent state lock so a concurrent second instance adopts the first', async () => { const { root, statePath } = await createTempStatePath(); const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; diff --git a/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts index caf838e6..569726b7 100644 --- a/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts +++ b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts @@ -43,7 +43,8 @@ describe('NativeAppManagedBootstrapContextBuilder', () => { { name: 'alice', providerId: 'anthropic', - role: 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret', + role: + 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret ANTHROPIC_AUTH_TOKEN="lmstudio local token"', }, { name: 'bob', @@ -69,7 +70,8 @@ describe('NativeAppManagedBootstrapContextBuilder', () => { { name: 'alice', providerId: 'anthropic', - role: 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret', + role: + 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret ANTHROPIC_AUTH_TOKEN="lmstudio local token"', }, { name: 'bob', @@ -95,6 +97,9 @@ describe('NativeAppManagedBootstrapContextBuilder', () => { expect(alice?.contextText).toContain(''); expect(alice?.contextText).not.toContain('sk-ant-secret'); expect(alice?.contextText).toContain('ANTHROPIC_API_KEY=[REDACTED]'); + expect(alice?.contextText).toContain('ANTHROPIC_AUTH_TOKEN=[REDACTED]'); + expect(alice?.contextText).not.toContain('lmstudio'); + expect(alice?.contextText).not.toContain('local token'); expect(bob?.contextText).not.toContain('Bearer secret-token'); expect(bob?.contextText).toContain('Bearer [REDACTED]'); expect(bob?.contextText).toContain('Codex Native visible messaging rule'); diff --git a/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts b/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts index a6a1fdb3..0674650c 100644 --- a/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts +++ b/test/main/services/team/TeamLaunchFailureArtifactPack.test.ts @@ -148,10 +148,16 @@ describe('TeamLaunchFailureArtifactPack', () => { it('redacts common bearer and token-shaped secrets', () => { const redacted = redactLaunchFailureArtifactText( - 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456' + 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456 ANTHROPIC_AUTH_TOKEN=lmstudio CODEX_API_KEY="quoted-codex-token" OPENROUTER_API_KEY=\'quoted-router-token\'' ); expect(redacted).toContain('Authorization: Bearer [REDACTED]'); expect(redacted).toContain('token: [REDACTED]'); + expect(redacted).toContain('ANTHROPIC_AUTH_TOKEN=[REDACTED]'); + expect(redacted).toContain('CODEX_API_KEY=[REDACTED]'); + expect(redacted).toContain('OPENROUTER_API_KEY=[REDACTED]'); + expect(redacted).not.toContain('lmstudio'); + expect(redacted).not.toContain('quoted-codex-token'); + expect(redacted).not.toContain('quoted-router-token'); }); it('classifies bootstrap transport rejection and extracts breadcrumb details', () => { diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index aa069ef6..23f377bf 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -324,9 +324,10 @@ describe('TeamMcpConfigBuilder', () => { env: expect.objectContaining({ PATH: expect.any(String) }), }) ); + expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); }); - it('resolves packaged MCP Node through shell PATH instead of writing a bare node command', async () => { + it('resolves packaged MCP Node through cached shell PATH without spawning shell', async () => { setPackagedMode(true, '2.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); @@ -350,7 +351,79 @@ describe('TeamMcpConfigBuilder', () => { expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node'); expect(readGeneratedServer(configPath)?.command).not.toBe('node'); - expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(expect.any(Object)); + expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); + }); + + it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => { + mockBuiltWorkspaceEntryAvailable(); + const previousNodeBinary = process.env.NODE_BINARY; + const previousNpmNodeExecPath = process.env.npm_node_execpath; + delete process.env.NODE_BINARY; + delete process.env.npm_node_execpath; + hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ + PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), + HOME: '/Users/tester', + }); + hoisted.execCliMock.mockImplementation(async (command, _args, options) => { + const env = options?.env as NodeJS.ProcessEnv | undefined; + if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { + expect(command).toBe('node'); + return { stdout: '/strict-shell-node-bin/node', stderr: '' }; + } + throw new Error(`spawn ${command} ENOENT`); + }); + + try { + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); + expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ source: 'mcp-node-runtime' }) + ); + } finally { + if (previousNodeBinary === undefined) { + delete process.env.NODE_BINARY; + } else { + process.env.NODE_BINARY = previousNodeBinary; + } + if (previousNpmNodeExecPath === undefined) { + delete process.env.npm_node_execpath; + } else { + process.env.npm_node_execpath = previousNpmNodeExecPath; + } + } + }); + + it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => { + mockBuiltWorkspaceEntryAvailable(); + hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ + PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter), + HOME: '/Users/tester', + }); + let returnedEmptyPath = false; + hoisted.execCliMock.mockImplementation(async (command, _args, options) => { + const env = options?.env as NodeJS.ProcessEnv | undefined; + if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { + expect(command).toBe('node'); + return { stdout: '/strict-shell-node-bin/node', stderr: '' }; + } + if (!returnedEmptyPath) { + returnedEmptyPath = true; + return { stdout: ' ', stderr: '' }; + } + throw new Error(`spawn ${command} ENOENT`); + }); + + const builder = new TeamMcpConfigBuilder(); + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node'); + expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ source: 'mcp-node-runtime' }) + ); }); it('prefers an explicit NODE_BINARY over PATH-based node lookup', async () => { @@ -368,6 +441,7 @@ describe('TeamMcpConfigBuilder', () => { createdPaths.push(configPath); expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node'); + expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled(); } finally { if (previousNodeBinary === undefined) { delete process.env.NODE_BINARY; diff --git a/test/main/services/team/progressPayload.test.ts b/test/main/services/team/progressPayload.test.ts index 46aeb729..10fafb3d 100644 --- a/test/main/services/team/progressPayload.test.ts +++ b/test/main/services/team/progressPayload.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - PROGRESS_LOG_TAIL_LINES, - PROGRESS_OUTPUT_TAIL_PARTS, - PROGRESS_TRACE_TAIL_LINES, boundLaunchDiagnostics, buildProgressAssistantOutput, buildProgressLiveOutput, buildProgressLogsTail, buildProgressTraceLine, buildProgressTraceTail, + PROGRESS_LOG_TAIL_LINES, + PROGRESS_OUTPUT_TAIL_PARTS, + PROGRESS_TRACE_TAIL_LINES, } from '../../../../src/main/services/team/progressPayload'; describe('buildProgressLogsTail', () => { @@ -86,16 +86,22 @@ describe('buildProgressTraceLine', () => { const result = buildProgressTraceLine({ timestamp: '2026-04-28T12:00:00.000Z', state: 'spawning', - message: 'Starting runtime --api-key sk-test', - detail: 'OPENAI_API_KEY=super-secret CODEX_API_KEY="also-secret" ```', + message: 'Starting runtime --api-key sk-test Authorization: Bearer local-bearer-token', + detail: + 'OPENAI_API_KEY=super-secret CODEX_API_KEY="also-secret" ANTHROPIC_AUTH_TOKEN="lmstudio local token" ```', }); expect(result).toContain('--api-key [redacted]'); expect(result).toContain('OPENAI_API_KEY=[redacted]'); expect(result).toContain('CODEX_API_KEY=[redacted]'); + expect(result).toContain('ANTHROPIC_AUTH_TOKEN=[redacted]'); + expect(result).toContain('Authorization: Bearer [redacted]'); expect(result).not.toContain('sk-test'); expect(result).not.toContain('super-secret'); expect(result).not.toContain('also-secret'); + expect(result).not.toContain('lmstudio'); + expect(result).not.toContain('local token'); + expect(result).not.toContain('local-bearer-token'); expect(result).not.toContain('```'); }); }); diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx index 675db1f5..9a61a8f6 100644 --- a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + import { afterEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ @@ -270,8 +271,9 @@ describe('ProvisioningProgressBlock', () => { defaultLiveOutputOpen: true, startedAt: '2026-04-28T12:00:00.000Z', pid: 321, - assistantOutput: 'Launch trace line', - cliLogsTail: '[stderr] OPENAI_API_KEY=secret-value\n[stdout] booted', + assistantOutput: 'Launch trace line Authorization: Bearer assistant-token', + cliLogsTail: + '[stderr] OPENAI_API_KEY=secret-value ANTHROPIC_AUTH_TOKEN="local token"\n[stdout] booted', warnings: ['Large Codex team launch: 9 primary teammates will bootstrap in one runtime.'], launchDiagnostics: [ { @@ -316,8 +318,12 @@ describe('ProvisioningProgressBlock', () => { expect(copied).toContain('Launch trace line'); expect(copied).toContain('[stdout] booted'); expect(copied).toContain('OPENAI_API_KEY=[redacted]'); + expect(copied).toContain('ANTHROPIC_AUTH_TOKEN=[redacted]'); + expect(copied).toContain('Authorization: Bearer [redacted]'); expect(copied).toContain('--api-key [redacted]'); expect(copied).not.toContain('secret-value'); + expect(copied).not.toContain('local token'); + expect(copied).not.toContain('assistant-token'); expect(copied).not.toContain('hidden-value'); await act(async () => {