fix(team): harden launch diagnostics and MCP shutdown
This commit is contained in:
parent
b5ca3eed68
commit
a386e30667
19 changed files with 270 additions and 87 deletions
|
|
@ -2247,7 +2247,7 @@ async function shutdownServices(): Promise<void> {
|
|||
10_000
|
||||
);
|
||||
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
|
||||
agentTeamsMcpHttpServer.stop()
|
||||
agentTeamsMcpHttpServer.stop({ preventRestart: true })
|
||||
);
|
||||
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
||||
killTrackedCliProcesses('SIGKILL')
|
||||
|
|
|
|||
|
|
@ -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!, {
|
||||
|
|
|
|||
|
|
@ -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<ChildProcess>();
|
||||
private readonly ownerInstanceId = randomUUID();
|
||||
private readonly startedAtMs = Date.now();
|
||||
private preventFutureStarts = false;
|
||||
|
||||
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
||||
|
||||
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
this.throwIfStartsPrevented();
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
|
@ -697,7 +701,10 @@ export class AgentTeamsMcpHttpServer {
|
|||
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 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<AgentTeamsMcpHttpServerHandle> {
|
||||
|
|
@ -1008,8 +1021,10 @@ export class AgentTeamsMcpHttpServer {
|
|||
reason?: string;
|
||||
} = {}
|
||||
): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>
|
|||
}
|
||||
|
||||
export async function getDoctorInvokedCandidates(commandName: string): Promise<string[]> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const output = await captureDoctorOutput(commandName);
|
||||
if (!output) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
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<s
|
|||
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;
|
||||
return { ok: true, path: resolved };
|
||||
} catch (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.');
|
||||
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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ const CLI_ENV_DEFAULTS: Record<string, string> = {
|
|||
|
||||
const activeCliProcesses = new Set<ChildProcess>();
|
||||
|
||||
function untrackCliProcess(child: ChildProcess | null): void {
|
||||
export function untrackCliProcess(child: ChildProcess | null): void {
|
||||
if (child) {
|
||||
activeCliProcesses.delete(child);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<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 () => {
|
||||
const { root, statePath } = await createTempStatePath();
|
||||
const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] };
|
||||
|
|
|
|||
|
|
@ -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('<agent_teams_native_bootstrap_context>');
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('```');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue