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,8 +2786,8 @@ function buildMessageDeliveryText(
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
];
hiddenBlocks.push(
wrapAgentBlock(
[
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.`,
@ -2805,8 +2805,8 @@ function buildMessageDeliveryText(
'Do NOT stay silent while you go ask someone else.',
]
: []),
AGENT_BLOCK_CLOSE,
].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,
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.`,
AGENT_BLOCK_CLOSE,
].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}`,
'',
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}" }`,
AGENT_BLOCK_CLOSE
].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 () => {