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
);
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
agentTeamsMcpHttpServer.stop()
agentTeamsMcpHttpServer.stop({ preventRestart: true })
);
await runShutdownStep('tracked CLI subprocess cleanup', () =>
killTrackedCliProcesses('SIGKILL')

View file

@ -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!, {

View file

@ -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;
}

View file

@ -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 [];

View file

@ -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,

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-[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.

View file

@ -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)
}`
);
}

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 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 {

View file

@ -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]');
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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 =

View file

@ -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'] };

View file

@ -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');

View file

@ -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', () => {

View file

@ -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;

View file

@ -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('```');
});
});

View file

@ -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 () => {