fix(team): harden launch diagnostics and MCP shutdown

This commit is contained in:
777genius 2026-05-22 00:20:47 +03:00
parent b5ca3eed68
commit a386e30667
19 changed files with 270 additions and 87 deletions

View file

@ -2247,7 +2247,7 @@ async function shutdownServices(): Promise<void> {
10_000 10_000
); );
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () => await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
agentTeamsMcpHttpServer.stop() agentTeamsMcpHttpServer.stop({ preventRestart: true })
); );
await runShutdownStep('tracked CLI subprocess cleanup', () => await runShutdownStep('tracked CLI subprocess cleanup', () =>
killTrackedCliProcesses('SIGKILL') killTrackedCliProcesses('SIGKILL')

View file

@ -94,7 +94,7 @@ import {
TEAM_VALIDATE_CLI_ARGS, TEAM_VALIDATE_CLI_ARGS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels'; } 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 { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; 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.', 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
]; ];
hiddenBlocks.push( hiddenBlocks.push(
[ wrapAgentBlock(
AGENT_BLOCK_OPEN, [
`You received a direct message from ${senderDescriptor} via the UI.`, `You received a direct message from ${senderDescriptor} via the UI.`,
...replyInstructionLines, ...replyInstructionLines,
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, `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").', 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
...(canUseAgentTeamsMessageSend ...(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.', '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 ...(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: "Принял, сейчас уточню и вернусь с ответом.").', '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.', '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".', 'After you get the needed information, send the final answer back to "user".',
'Do NOT stay silent while you go ask someone else.', 'Do NOT stay silent while you go ask someone else.',
] ]
: []), : []),
AGENT_BLOCK_CLOSE, ].join('\n')
].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.`, `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] : []), ...(rosterContextBlock ? [rosterContextBlock] : []),
...(delegateAckBlock ? [delegateAckBlock] : []), ...(delegateAckBlock ? [delegateAckBlock] : []),
AGENT_BLOCK_OPEN, 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.`, `MessageId: ${preGeneratedMessageId}`,
AGENT_BLOCK_CLOSE, `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:`, `Message from user:`,
buildMessageDeliveryText(payload.text!, { buildMessageDeliveryText(payload.text!, {

View file

@ -8,7 +8,7 @@ import * as path from 'node:path';
import { type RuntimeProcessTableRow } from '@features/tmux-installer/main'; import { type RuntimeProcessTableRow } from '@features/tmux-installer/main';
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore'; import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
import { atomicWriteAsync } from '@main/utils/atomicWrite'; 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 { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill'; import { killProcessByPid } from '@main/utils/processKill';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
@ -332,11 +332,13 @@ function defaultSpawnProcess(
args: string[], args: string[],
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
): ChildProcess { ): ChildProcess {
return spawnCli(command, args, { const child = spawnCli(command, args, {
env, env,
stdio: ['ignore', 'ignore', 'pipe'], stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true, windowsHide: true,
}); });
untrackCliProcess(child);
return child;
} }
function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] { function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] {
@ -681,10 +683,12 @@ export class AgentTeamsMcpHttpServer {
private readonly expectedStopChildren = new WeakSet<ChildProcess>(); private readonly expectedStopChildren = new WeakSet<ChildProcess>();
private readonly ownerInstanceId = randomUUID(); private readonly ownerInstanceId = randomUUID();
private readonly startedAtMs = Date.now(); private readonly startedAtMs = Date.now();
private preventFutureStarts = false;
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> { async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
this.throwIfStartsPrevented();
if (this.startPromise) { if (this.startPromise) {
return this.startPromise; return this.startPromise;
} }
@ -697,7 +701,10 @@ export class AgentTeamsMcpHttpServer {
return this.startPromise; return this.startPromise;
} }
async stop(): Promise<void> { async stop(input: { preventRestart?: boolean } = {}): Promise<void> {
if (input.preventRestart) {
this.preventFutureStarts = true;
}
const child = this.child; const child = this.child;
const handle = this.handle; const handle = this.handle;
const releasePort = child ? (handle?.port ?? null) : null; const releasePort = child ? (handle?.port ?? null) : null;
@ -730,6 +737,12 @@ export class AgentTeamsMcpHttpServer {
return this.deps.statePath ?? buildStatePath(); 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( private async reuseOrRestartExistingHandle(
handle: AgentTeamsMcpHttpServerHandle handle: AgentTeamsMcpHttpServerHandle
): Promise<AgentTeamsMcpHttpServerHandle> { ): Promise<AgentTeamsMcpHttpServerHandle> {
@ -1008,8 +1021,10 @@ export class AgentTeamsMcpHttpServer {
reason?: string; reason?: string;
} = {} } = {}
): Promise<AgentTeamsMcpHttpServerHandle> { ): Promise<AgentTeamsMcpHttpServerHandle> {
this.throwIfStartsPrevented();
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec; const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
const launchSpec = await resolveLaunchSpec(); const launchSpec = await resolveLaunchSpec();
this.throwIfStartsPrevented();
const expectedIdentity = buildExpectedIdentity(launchSpec, this.ownerInstanceId); const expectedIdentity = buildExpectedIdentity(launchSpec, this.ownerInstanceId);
const statePath = this.resolveStatePath(); const statePath = this.resolveStatePath();
const startUnlocked = async (effectiveStatePath: string | null, diagnostics: string[]) => const startUnlocked = async (effectiveStatePath: string | null, diagnostics: string[]) =>
@ -1045,6 +1060,7 @@ export class AgentTeamsMcpHttpServer {
const diagnostics = [...initialDiagnostics]; const diagnostics = [...initialDiagnostics];
const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess; const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess;
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort; const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
this.throwIfStartsPrevented();
if (statePath) { if (statePath) {
const adopted = await this.tryAdoptStateHandle(statePath, expectedIdentity, diagnostics); const adopted = await this.tryAdoptStateHandle(statePath, expectedIdentity, diagnostics);
@ -1059,6 +1075,7 @@ export class AgentTeamsMcpHttpServer {
statePath, statePath,
diagnostics diagnostics
); );
this.throwIfStartsPrevented();
if (selectedTarget.kind === 'handle') { if (selectedTarget.kind === 'handle') {
return selectedTarget.handle; return selectedTarget.handle;
} }

View file

@ -1,5 +1,5 @@
import { buildEnrichedEnv } from '@main/utils/cliEnv'; 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 { createLogger } from '@shared/utils/logger';
import type { IPty } from 'node-pty'; import type { IPty } from 'node-pty';
@ -173,7 +173,6 @@ async function captureDoctorOutput(commandName: string): Promise<string | null>
} }
export async function getDoctorInvokedCandidates(commandName: string): Promise<string[]> { export async function getDoctorInvokedCandidates(commandName: string): Promise<string[]> {
await resolveInteractiveShellEnv();
const output = await captureDoctorOutput(commandName); const output = await captureDoctorOutput(commandName);
if (!output) { if (!output) {
return []; return [];

View file

@ -2,12 +2,7 @@ import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/
import { yieldToEventLoop } from '@main/utils/asyncYield'; import { yieldToEventLoop } from '@main/utils/asyncYield';
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill'; import { killProcessByPid } from '@main/utils/processKill';
import { import { stripAgentBlocks, wrapAgentBlock } from '@shared/constants/agentBlocks';
AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN,
stripAgentBlocks,
wrapAgentBlock,
} from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
@ -2053,11 +2048,14 @@ export class TeamDataService {
parts.push(`\nDetails:\n${task.description.trim()}`); parts.push(`\nDetails:\n${task.description.trim()}`);
} }
parts.push( 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.`, wrapAgentBlock(
`Update task status using the board MCP tools:`, [
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`,
AGENT_BLOCK_CLOSE `Update task status using the board MCP tools:`,
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
].join('\n')
)
); );
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: task.owner, member: task.owner,

View file

@ -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-proj-[A-Za-z0-9_-]{20,}/g, '[REDACTED_OPENAI_API_KEY]')
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]') .replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
.replace( .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]' '$1=[REDACTED]'
) )
// eslint-disable-next-line sonarjs/duplicates-in-character-class -- URL-safe token alphabet intentionally includes these literal characters. // eslint-disable-next-line sonarjs/duplicates-in-character-class -- URL-safe token alphabet intentionally includes these literal characters.

View file

@ -258,28 +258,9 @@ function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return env; return env;
} }
/** async function probeNodeRuntimePath(
* Find the real `node` binary path. In Electron, process.execPath is the env: NodeJS.ProcessEnv
* Electron binary — NOT node — so we must resolve node separately. ): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
* 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<string> {
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);
let lastError: unknown = null; let lastError: unknown = null;
for (const command of getNodeRuntimeCommandCandidates()) { for (const command of getNodeRuntimeCommandCandidates()) {
try { try {
@ -292,18 +273,55 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
if (!resolved) { if (!resolved) {
throw new Error(`${command} did not report process.execPath`); throw new Error(`${command} did not report process.execPath`);
} }
_resolvedNodePath = resolved; return { ok: true, path: resolved };
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
} }
} }
return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
}
/**
* 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<string> {
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.'); emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
throw new Error( 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: ${ `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)
}` }`
); );
} }

View file

@ -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 * as agentTeamsControllerModule from 'agent-teams-controller';
import type { AgentActionMode } from '@shared/types'; import type { AgentActionMode } from '@shared/types';
@ -46,7 +46,7 @@ export function buildActionModeAgentBlock(mode: AgentActionMode | undefined): st
} }
const lines = ACTION_MODE_BLOCKS[mode]; 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 { export function isAgentActionMode(value: unknown): value is AgentActionMode {

View file

@ -75,7 +75,10 @@ function redactNativeBootstrapContextText(input: string): string {
return input return input
.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]') .replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]')
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_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]'); .replace(/Bearer\s+[A-Z0-9._-]+/gi, 'Bearer [REDACTED]');
} }

View file

@ -26,6 +26,7 @@ const SECRET_FLAG_PATTERN =
/(--(?:api[-_]key|token|password|secret|authorization|auth[-_]token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /(--(?:api[-_]key|token|password|secret|authorization|auth[-_]token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_ENV_ASSIGNMENT_PATTERN = const SECRET_ENV_ASSIGNMENT_PATTERN =
/\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi; /\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" * 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(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]')
.replace(SECRET_FLAG_PATTERN, '$1[redacted]') .replace(SECRET_FLAG_PATTERN, '$1[redacted]')
.replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]') .replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]')
.replace(AUTH_HEADER_PATTERN, '$1$2[redacted]')
.replace(/```/g, "'''"); .replace(/```/g, "'''");
return redacted.length > limit ? `${redacted.slice(0, limit - 3).trimEnd()}...` : redacted; return redacted.length > limit ? `${redacted.slice(0, limit - 3).trimEnd()}...` : redacted;
} }

View file

@ -21,6 +21,7 @@ interface RuntimeDiagnosticRule {
const SECRET_VALUE_PATTERNS = [ const SECRET_VALUE_PATTERNS = [
/\bsk-[A-Z0-9_-]{12,}\b/gi, /\bsk-[A-Z0-9_-]{12,}\b/gi,
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/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, /\bauthorization:\s*bearer\s+[^'"\s]+/gi,
] as const; ] as const;

View file

@ -291,7 +291,7 @@ const CLI_ENV_DEFAULTS: Record<string, string> = {
const activeCliProcesses = new Set<ChildProcess>(); const activeCliProcesses = new Set<ChildProcess>();
function untrackCliProcess(child: ChildProcess | null): void { export function untrackCliProcess(child: ChildProcess | null): void {
if (child) { if (child) {
activeCliProcesses.delete(child); activeCliProcesses.delete(child);
} }

View file

@ -76,7 +76,7 @@ const MAX_PERMISSION_REQUEST_IDS = 10;
const SECRET_FLAG_PATTERN = const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_VALUE_PATTERN = 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 = const OPENCODE_SESSION_REFRESH_REASON_PATTERN =
/\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i; /\b(?:resolved_behavior_changed|opencode_app_mcp_transport_changed):[-a-z0-9._~/=]+->[-a-z0-9._~/=]+/i;
const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN = const OPENCODE_SESSION_REFRESH_FAILURE_PATTERN =

View file

@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
killProcessTreeMock: vi.fn(), killProcessTreeMock: vi.fn(),
spawnCliMock: vi.fn(), spawnCliMock: vi.fn(),
untrackCliProcessMock: vi.fn(),
})); }));
vi.mock('@main/utils/childProcess', async (importOriginal) => { vi.mock('@main/utils/childProcess', async (importOriginal) => {
@ -21,6 +22,7 @@ vi.mock('@main/utils/childProcess', async (importOriginal) => {
...actual, ...actual,
killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args), killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args),
spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args), spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args),
untrackCliProcess: (...args: unknown[]) => hoisted.untrackCliProcessMock(...args),
}; };
}); });
@ -114,6 +116,7 @@ describe('AgentTeamsMcpHttpServer', () => {
beforeEach(() => { beforeEach(() => {
hoisted.killProcessTreeMock.mockReset(); hoisted.killProcessTreeMock.mockReset();
hoisted.spawnCliMock.mockReset(); hoisted.spawnCliMock.mockReset();
hoisted.untrackCliProcessMock.mockReset();
}); });
it('starts the MCP server over HTTP with hidden app-owned process env', async () => { it('starts the MCP server over HTTP with hidden app-owned process env', async () => {
@ -212,6 +215,7 @@ describe('AgentTeamsMcpHttpServer', () => {
windowsHide: true, windowsHide: true,
}) })
); );
expect(hoisted.untrackCliProcessMock).toHaveBeenCalledWith(child);
}); });
it('coalesces concurrent starts', async () => { it('coalesces concurrent starts', async () => {
@ -234,6 +238,48 @@ describe('AgentTeamsMcpHttpServer', () => {
expect(spawnProcess).toHaveBeenCalledTimes(1); 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<void>((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 () => { it('uses the persistent state lock so a concurrent second instance adopts the first', async () => {
const { root, statePath } = await createTempStatePath(); const { root, statePath } = await createTempStatePath();
const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] };

View file

@ -43,7 +43,8 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
{ {
name: 'alice', name: 'alice',
providerId: 'anthropic', 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', name: 'bob',
@ -69,7 +70,8 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
{ {
name: 'alice', name: 'alice',
providerId: 'anthropic', 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', name: 'bob',
@ -95,6 +97,9 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
expect(alice?.contextText).toContain('<agent_teams_native_bootstrap_context>'); expect(alice?.contextText).toContain('<agent_teams_native_bootstrap_context>');
expect(alice?.contextText).not.toContain('sk-ant-secret'); expect(alice?.contextText).not.toContain('sk-ant-secret');
expect(alice?.contextText).toContain('ANTHROPIC_API_KEY=[REDACTED]'); 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).not.toContain('Bearer secret-token');
expect(bob?.contextText).toContain('Bearer [REDACTED]'); expect(bob?.contextText).toContain('Bearer [REDACTED]');
expect(bob?.contextText).toContain('Codex Native visible messaging rule'); expect(bob?.contextText).toContain('Codex Native visible messaging rule');

View file

@ -148,10 +148,16 @@ describe('TeamLaunchFailureArtifactPack', () => {
it('redacts common bearer and token-shaped secrets', () => { it('redacts common bearer and token-shaped secrets', () => {
const redacted = redactLaunchFailureArtifactText( 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('Authorization: Bearer [REDACTED]');
expect(redacted).toContain('token: [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', () => { it('classifies bootstrap transport rejection and extracts breadcrumb details', () => {

View file

@ -324,9 +324,10 @@ describe('TeamMcpConfigBuilder', () => {
env: expect.objectContaining({ PATH: expect.any(String) }), 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'); setPackagedMode(true, '2.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir); createdDirs.push(resourcesDir);
@ -350,7 +351,79 @@ describe('TeamMcpConfigBuilder', () => {
expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node'); expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node');
expect(readGeneratedServer(configPath)?.command).not.toBe('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 () => { it('prefers an explicit NODE_BINARY over PATH-based node lookup', async () => {
@ -368,6 +441,7 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath); createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node'); expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node');
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
} finally { } finally {
if (previousNodeBinary === undefined) { if (previousNodeBinary === undefined) {
delete process.env.NODE_BINARY; delete process.env.NODE_BINARY;

View file

@ -1,15 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
PROGRESS_LOG_TAIL_LINES,
PROGRESS_OUTPUT_TAIL_PARTS,
PROGRESS_TRACE_TAIL_LINES,
boundLaunchDiagnostics, boundLaunchDiagnostics,
buildProgressAssistantOutput, buildProgressAssistantOutput,
buildProgressLiveOutput, buildProgressLiveOutput,
buildProgressLogsTail, buildProgressLogsTail,
buildProgressTraceLine, buildProgressTraceLine,
buildProgressTraceTail, buildProgressTraceTail,
PROGRESS_LOG_TAIL_LINES,
PROGRESS_OUTPUT_TAIL_PARTS,
PROGRESS_TRACE_TAIL_LINES,
} from '../../../../src/main/services/team/progressPayload'; } from '../../../../src/main/services/team/progressPayload';
describe('buildProgressLogsTail', () => { describe('buildProgressLogsTail', () => {
@ -86,16 +86,22 @@ describe('buildProgressTraceLine', () => {
const result = buildProgressTraceLine({ const result = buildProgressTraceLine({
timestamp: '2026-04-28T12:00:00.000Z', timestamp: '2026-04-28T12:00:00.000Z',
state: 'spawning', state: 'spawning',
message: 'Starting runtime --api-key sk-test', message: 'Starting runtime --api-key sk-test Authorization: Bearer local-bearer-token',
detail: 'OPENAI_API_KEY=super-secret CODEX_API_KEY="also-secret" ```', 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('--api-key [redacted]');
expect(result).toContain('OPENAI_API_KEY=[redacted]'); expect(result).toContain('OPENAI_API_KEY=[redacted]');
expect(result).toContain('CODEX_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('sk-test');
expect(result).not.toContain('super-secret'); expect(result).not.toContain('super-secret');
expect(result).not.toContain('also-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('```'); expect(result).not.toContain('```');
}); });
}); });

View file

@ -1,5 +1,6 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
@ -270,8 +271,9 @@ describe('ProvisioningProgressBlock', () => {
defaultLiveOutputOpen: true, defaultLiveOutputOpen: true,
startedAt: '2026-04-28T12:00:00.000Z', startedAt: '2026-04-28T12:00:00.000Z',
pid: 321, pid: 321,
assistantOutput: 'Launch trace line', assistantOutput: 'Launch trace line Authorization: Bearer assistant-token',
cliLogsTail: '[stderr] OPENAI_API_KEY=secret-value\n[stdout] booted', 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.'], warnings: ['Large Codex team launch: 9 primary teammates will bootstrap in one runtime.'],
launchDiagnostics: [ launchDiagnostics: [
{ {
@ -316,8 +318,12 @@ describe('ProvisioningProgressBlock', () => {
expect(copied).toContain('Launch trace line'); expect(copied).toContain('Launch trace line');
expect(copied).toContain('[stdout] booted'); expect(copied).toContain('[stdout] booted');
expect(copied).toContain('OPENAI_API_KEY=[redacted]'); 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).toContain('--api-key [redacted]');
expect(copied).not.toContain('secret-value'); expect(copied).not.toContain('secret-value');
expect(copied).not.toContain('local token');
expect(copied).not.toContain('assistant-token');
expect(copied).not.toContain('hidden-value'); expect(copied).not.toContain('hidden-value');
await act(async () => { await act(async () => {