Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "powerplatform-toolbox",
"version": "1.2.5",
"version": "1.2.6",
"description": "A universal desktop app that contains multiple tools to ease the customization and configuration of Power Platform",
"main": "dist/main/index.js",
"bin": {
Expand Down Expand Up @@ -89,4 +89,4 @@
"build": {
"extends": null
}
}
}
1 change: 1 addition & 0 deletions src/common/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const CONNECTION_CHANNELS = {
export const TOOL_CHANNELS = {
GET_ALL_TOOLS: "get-all-tools",
GET_TOOL: "get-tool",
RESOLVE_INVOCATION_TARGET: "resolve-invocation-target",
LOAD_TOOL: "load-tool",
UNLOAD_TOOL: "unload-tool",
INSTALL_TOOL: "install-tool",
Expand Down
1 change: 1 addition & 0 deletions src/common/types/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface ToolRegistryEntry {
*/
export interface ToolManifest {
id: string;
packageName?: string; // Canonical package.json name used for inter-tool invocation lookup
name: string;
version: string;
description: string;
Expand Down
44 changes: 43 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
UTIL_CHANNELS,
} from "../common/ipc/channels";
import { logCheckpoint, logError, logInfo, logWarn } from "../common/logger";
import { captureException, captureMessage } from "../common/sentryHelper";
import {
AttributeMetadataType,
EntityRelatedMetadataPath,
Expand Down Expand Up @@ -345,6 +346,7 @@ class ToolBoxApp {
// Tool handlers
ipcMain.removeHandler(TOOL_CHANNELS.GET_ALL_TOOLS);
ipcMain.removeHandler(TOOL_CHANNELS.GET_TOOL);
ipcMain.removeHandler(TOOL_CHANNELS.RESOLVE_INVOCATION_TARGET);
ipcMain.removeHandler(TOOL_CHANNELS.LOAD_TOOL);
ipcMain.removeHandler(TOOL_CHANNELS.UNLOAD_TOOL);
ipcMain.removeHandler(TOOL_CHANNELS.INSTALL_TOOL_FROM_REGISTRY);
Expand Down Expand Up @@ -1005,10 +1007,50 @@ class ToolBoxApp {
return this.toolManager.getAllTools();
});

ipcMain.handle(TOOL_CHANNELS.GET_TOOL, (_, toolId) => {
ipcMain.handle(TOOL_CHANNELS.GET_TOOL, (_, toolId: string) => {
return this.toolManager.getTool(toolId);
});

ipcMain.handle(TOOL_CHANNELS.RESOLVE_INVOCATION_TARGET, (event, targetIdentifier: string, callerInstanceId: string) => {
try {
if (!this.toolWindowManager || this.toolWindowManager.getInstanceIdByWebContents(event.sender.id) !== callerInstanceId) {
throw new Error("Invocation caller does not match the sending tool instance");
}

const tool = this.toolManager.resolveInvocationTarget(targetIdentifier);
const logContext = { callerInstanceId, targetIdentifier, resolvedToolId: tool?.id };
if (tool) {
logInfo("[ToolInvocation] Target tool resolved", logContext);
} else {
logWarn("[ToolInvocation] Target tool was not found", logContext);
captureMessage("Inter-tool invocation target was not found", "warning", {
tags: { operation: "resolveInvocationTarget", failure_stage: "target_resolution" },
extra: logContext,
});
this.api.showNotification({
title: "Tool Not Found",
body: "The requested tool is not installed or available.",
type: "warning",
});
}
return tool;
} catch (error) {
const lookupError = error instanceof Error ? error : new Error(String(error));
const logContext = { callerInstanceId, targetIdentifier };
logError("[ToolInvocation] Target tool lookup failed", { ...logContext, error: lookupError.message });
captureException(lookupError, {
tags: { operation: "resolveInvocationTarget", failure_stage: "target_resolution" },
extra: logContext,
});
this.api.showNotification({
title: "Tool Lookup Failed",
body: "Failed to resolve the requested tool.",
type: "error",
});
throw error;
}
});

ipcMain.handle(TOOL_CHANNELS.LOAD_TOOL, async (_, packageName) => {
return await this.toolManager.loadTool(packageName);
});
Expand Down
30 changes: 30 additions & 0 deletions src/main/managers/toolRegistryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,7 @@ export class ToolRegistryManager extends EventEmitter {

const manifest: ToolManifest = {
id: tool.id || packageJson.name,
packageName: packageJson.name,
name: tool.name || packageJson.displayName || packageJson.name,
version: tool.version || packageJson.version,
description: tool.description || packageJson.description,
Expand Down Expand Up @@ -946,9 +947,36 @@ export class ToolRegistryManager extends EventEmitter {
private normalizeManifestEntry(entry: Record<string, unknown>): ToolManifest {
const manifestEntry = entry as unknown as ToolManifest & { tags?: string[]; author?: string | { name?: string } };
const categories = (manifestEntry.categories as string[] | undefined) ?? (manifestEntry as unknown as { tags?: string[] }).tags ?? [];
let packageName = manifestEntry.packageName;
let authors: string[] | undefined = this.normalizeAuthorList((manifestEntry as unknown as { authors?: unknown }).authors);
const legacyAuthor = (manifestEntry as unknown as { author?: string | { name?: string } }).author;

if (!packageName && typeof manifestEntry.installPath === "string") {
try {
const toolsRoot = fs.realpathSync(this.toolsDirectory);
const installPath = fs.realpathSync(manifestEntry.installPath);
const relativeInstallPath = path.relative(toolsRoot, installPath);
const isWithinToolsDirectory = relativeInstallPath !== "" && !relativeInstallPath.startsWith(`..${path.sep}`) && relativeInstallPath !== ".." && !path.isAbsolute(relativeInstallPath);

if (isWithinToolsDirectory) {
const packageJsonPath = fs.realpathSync(path.join(installPath, "package.json"));
const relativePackageJsonPath = path.relative(installPath, packageJsonPath);
const isWithinInstallPath =
relativePackageJsonPath !== "" && !relativePackageJsonPath.startsWith(`..${path.sep}`) && relativePackageJsonPath !== ".." && !path.isAbsolute(relativePackageJsonPath);
const packageJsonStats = fs.statSync(packageJsonPath);

if (isWithinInstallPath && packageJsonStats.isFile() && packageJsonStats.size <= 1_048_576) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { name?: unknown };
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
packageName = packageJson.name;
}
}
}
} catch {
// Legacy manifests may reference packages that are no longer present.
}
}

if ((!authors || authors.length === 0) && legacyAuthor) {
if (typeof legacyAuthor === "string") {
authors = [legacyAuthor];
Expand All @@ -959,6 +987,7 @@ export class ToolRegistryManager extends EventEmitter {

return {
id: manifestEntry.id,
packageName,
name: manifestEntry.name,
version: manifestEntry.version,
description: manifestEntry.description,
Expand All @@ -984,6 +1013,7 @@ export class ToolRegistryManager extends EventEmitter {
minAPI: manifestEntry.minAPI,
maxAPI: manifestEntry.maxAPI,
mcpHeadlessEnabled: manifestEntry.mcpHeadlessEnabled,
capabilities: manifestEntry.capabilities,
marketplaceSourceId: manifestEntry.marketplaceSourceId,
marketplaceSourceLabel: manifestEntry.marketplaceSourceLabel,
marketplaceSourceType: manifestEntry.marketplaceSourceType,
Expand Down
76 changes: 70 additions & 6 deletions src/main/managers/toolWindowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BrowserView, BrowserWindow, ipcMain, shell } from "electron";
import * as path from "path";
import { EVENT_CHANNELS, TOOL_WINDOW_CHANNELS } from "../../common/ipc/channels";
import { logError, logInfo, logWarn } from "../../common/logger";
import { captureException } from "../../common/sentryHelper";
import { LastUsedToolConnectionInfo, Tool } from "../../common/types";
import { ToolBoxEvent } from "../../common/types/events";
import { BrowserviewProtocolManager } from "./browserviewProtocolManager";
Expand Down Expand Up @@ -223,6 +224,10 @@ export class ToolWindowManager {
prefillData: Record<string, unknown>,
noReturn?: boolean,
) => {
if (this.getInstanceIdByWebContents(event.sender.id) !== callerInstanceId) {
throw new Error("Invocation caller does not match the sending tool instance");
}

return this.launchToolWithContext(callerInstanceId, calleeInstanceId, tool, primaryConnectionId, secondaryConnectionId, prefillData, noReturn);
},
);
Expand Down Expand Up @@ -344,7 +349,13 @@ export class ToolWindowManager {
*/
async launchTool(instanceId: string, tool: Tool, primaryConnectionId: string | null, secondaryConnectionId: string | null = null, prefillData?: Record<string, unknown>): Promise<boolean> {
try {
logInfo(`[ToolWindowManager] Launching tool instance: ${instanceId}`);
logInfo("[ToolWindowManager] Tool launch started", {
instanceId,
toolId: tool.id,
hasPrimaryConnection: primaryConnectionId !== null,
hasSecondaryConnection: secondaryConnectionId !== null,
hasPrefillData: prefillData !== undefined && Object.keys(prefillData).length > 0,
});

// Extract actual toolId from instanceId (format: toolId-timestamp-random)
const toolId = instanceId.split("-").slice(0, -2).join("-");
Expand Down Expand Up @@ -514,6 +525,18 @@ export class ToolWindowManager {
return true;
} catch (error) {
logError(`[ToolWindowManager] Error launching tool instance ${instanceId}`, error);
captureException(error instanceof Error ? error : new Error(String(error)), {
tags: {
operation: "launchTool",
tool_id: tool.id,
},
extra: {
instanceId,
hasPrimaryConnection: primaryConnectionId !== null,
hasSecondaryConnection: secondaryConnectionId !== null,
hasPrefillData: prefillData !== undefined && Object.keys(prefillData).length > 0,
},
});

return false;
}
Expand Down Expand Up @@ -550,12 +573,32 @@ export class ToolWindowManager {
noReturn?: boolean,
invocationContext?: InvocationContextMetadata,
): Promise<unknown> {
const invocationLogContext = {
callerInstanceId,
calleeInstanceId,
targetToolId: tool.id,
mode: noReturn ? "one-way" : "two-way",
source: invocationContext?.source ?? "tool",
correlationId: invocationContext?.correlationId,
hasPrimaryConnectionOverride: primaryConnectionId !== null,
hasSecondaryConnectionOverride: secondaryConnectionId !== null,
hasPrefillData: Object.keys(prefillData).length > 0,
};

logInfo("[ToolWindowManager] Inter-tool invocation requested", invocationLogContext);

// One-at-a-time enforcement
if (this.activeCallees.has(callerInstanceId)) {
throw new Error("A callee invocation is already in progress");
const error = new Error("A callee invocation is already in progress");
logError("[ToolWindowManager] Inter-tool invocation rejected", { ...invocationLogContext, error: error.message });
captureException(error, {
tags: { operation: "launchToolWithContext", tool_id: tool.id, failure_stage: "validation" },
extra: invocationLogContext,
});
throw error;
}

// FXS connection auto-inheritance: use caller's primary connection when none is specified
// Use caller's primary connection when none is specified
let effectivePrimaryConnectionId = primaryConnectionId ?? this.toolConnectionInfo.get(callerInstanceId)?.primaryConnectionId ?? null;

// Multi-connection: if the callee requires a secondary connection but none was provided,
Expand All @@ -568,11 +611,18 @@ export class ToolWindowManager {
const isSecondaryRequired = multiConnectionMode === "required";
const requestId = `invocation-conn-${callerInstanceId}-${Date.now()}`;
try {
logInfo("[ToolWindowManager] Inter-tool invocation awaiting connection selection", invocationLogContext);
const connectionResult = await this.promptForInvocationConnections(requestId, tool.name, isSecondaryRequired, effectivePrimaryConnectionId);
effectivePrimaryConnectionId = connectionResult.primaryConnectionId;
effectiveSecondaryConnectionId = connectionResult.secondaryConnectionId;
} catch (err) {
throw new Error(`Connection selection cancelled: ${err instanceof Error ? err.message : String(err)}`);
const error = new Error(`Connection selection cancelled: ${err instanceof Error ? err.message : String(err)}`);
logError("[ToolWindowManager] Inter-tool invocation connection selection failed", { ...invocationLogContext, error: error.message });
captureException(error, {
tags: { operation: "launchToolWithContext", tool_id: tool.id, failure_stage: "connection_selection" },
extra: invocationLogContext,
});
throw error;
}
}

Expand All @@ -587,15 +637,23 @@ export class ToolWindowManager {
invocationContext,
});
this.activeCallees.set(callerInstanceId, calleeInstanceId);
logInfo("[ToolWindowManager] Inter-tool invocation launching target", {
...invocationLogContext,
hasEffectivePrimaryConnection: effectivePrimaryConnectionId !== null,
hasEffectiveSecondaryConnection: effectiveSecondaryConnectionId !== null,
});

this.launchTool(calleeInstanceId, tool, effectivePrimaryConnectionId, effectiveSecondaryConnectionId, prefillData)
.then((launched) => {
if (!launched) {
this.pendingInvocations.delete(calleeInstanceId);
this.activeCallees.delete(callerInstanceId);
reject(new Error(`Failed to launch tool instance ${calleeInstanceId}`));
const error = new Error(`Failed to launch tool instance ${calleeInstanceId}`);
logError("[ToolWindowManager] Inter-tool invocation target launch failed", { ...invocationLogContext, error: error.message });
reject(error);
return;
}
logInfo("[ToolWindowManager] Inter-tool invocation target launched", invocationLogContext);
// Notify the renderer to create a tab for the callee so it appears as a
// separate instance (its own tab) rather than replacing the caller's view.
this.mainWindow.webContents.send(TOOL_WINDOW_CHANNELS.CALLEE_TOOL_OPENED, {
Expand All @@ -609,7 +667,13 @@ export class ToolWindowManager {
.catch((error) => {
this.pendingInvocations.delete(calleeInstanceId);
this.activeCallees.delete(callerInstanceId);
reject(error as Error);
const launchError = error instanceof Error ? error : new Error(String(error));
logError("[ToolWindowManager] Inter-tool invocation target launch rejected", { ...invocationLogContext, error: launchError.message });
captureException(launchError, {
tags: { operation: "launchToolWithContext", tool_id: tool.id, failure_stage: "target_launch" },
extra: invocationLogContext,
});
reject(launchError);
});
});
}
Expand Down
28 changes: 27 additions & 1 deletion src/main/managers/toolsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,31 @@ export class ToolManager extends EventEmitter {
return undefined;
}

resolveInvocationTarget(targetIdentifier: string): Tool | undefined {
const toolById = this.getTool(targetIdentifier);
if (toolById) {
return toolById;
}

const matchingTools = new Map<string, Tool>();
this.tools.forEach((tool) => {
if (tool.npmPackageName === targetIdentifier) {
matchingTools.set(tool.id, tool);
}
});
this.registryManager.getInstalledToolsSync().forEach((manifest) => {
if (manifest.packageName === targetIdentifier && !matchingTools.has(manifest.id)) {
matchingTools.set(manifest.id, this.createToolFromInstalledManifest(manifest));
}
});

if (matchingTools.size > 1) {
throw new Error(`Multiple installed tools match package name: ${targetIdentifier}`);
}

return matchingTools.values().next().value;
}

getInstalledManifestSync(toolId: string): ToolManifest | null {
return this.registryManager.getInstalledManifestSync(toolId);
}
Expand Down Expand Up @@ -727,7 +752,7 @@ export class ToolManager extends EventEmitter {
description: packageJson.description || "Tool installed from npm",
authors: typeof packageJson.author === "string" ? [packageJson.author] : undefined,
icon: packageJson.icon,
npmPackageName: packageName, // Store the npm package name for loading
npmPackageName: packageJson.name, // Store the canonical npm package name for loading and invocation lookup
cspExceptions: packageJson.cspExceptions, // Load CSP exceptions from package.json
features: packageJson.features, // Load features from package.json (e.g., multi-connection)
repository: typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url,
Expand Down Expand Up @@ -956,6 +981,7 @@ export class ToolManager extends EventEmitter {
authors: typeof packageJson.author === "string" ? [packageJson.author] : undefined,
icon: packageJson.icon,
localPath: localPath, // Store the local path for loading
npmPackageName: packageJson.name, // Store the canonical npm package name for invocation lookup
cspExceptions: packageJson.cspExceptions, // Load CSP exceptions from package.json
features: packageJson.features, // Load features from package.json (e.g., multi-connection)
repository: typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url,
Expand Down
Loading
Loading