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
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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,8 +2786,8 @@ 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.`,
|
||||||
|
|
@ -2805,8 +2805,8 @@ function buildMessageDeliveryText(
|
||||||
'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}`,
|
`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.`,
|
`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:`,
|
`Message from user:`,
|
||||||
buildMessageDeliveryText(payload.text!, {
|
buildMessageDeliveryText(payload.text!, {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
'',
|
||||||
|
wrapAgentBlock(
|
||||||
|
[
|
||||||
`Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`,
|
`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:`,
|
`Update task status using the board MCP tools:`,
|
||||||
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
||||||
AGENT_BLOCK_CLOSE
|
].join('\n')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: task.owner,
|
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-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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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'] };
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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('```');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue