diff --git a/.claude/workspace-trust-design.md b/.claude/workspace-trust-design.md new file mode 100644 index 0000000000..c7d029074e --- /dev/null +++ b/.claude/workspace-trust-design.md @@ -0,0 +1,142 @@ +# 工作区信任(受限模式)功能设计文档 + +## 概述 + +为 IDE 添加「工作区信任」机制,在首次打开项目时提示用户是否信任该项目目录。若用户选择「受限模式」,则 IDE 仅加载特定的安全插件,防止恶意脚本攻击。 + +## 背景 + +当前 IDE 打开项目时会自动加载并激活所有插件(extensions),包括第三方插件。如果项目目录来源不可信,其中的恶意插件脚本可能对用户的系统和数据造成威胁。VS Code 的「Workspace Trust」功能提供了类似的防护机制。 + +## 核心流程 + +``` +用户打开项目目录 + │ + ├── 查询 globalStorage 中是否已记录该目录的信任状态 + │ │ + │ ├── 已记录 → 直接使用已有状态 + │ └── 未记录 → 弹出信任对话框 + │ │ + │ ├── 选择「信任」 → 记录为信任状态 + │ └── 选择「受限模式」 → 记录为受限状态 + │ + ├── 根据信任状态决定是否加载插件 + │ │ + │ ├── 信任 → 加载所有插件 + │ └── 受限 → 仅加载白名单插件 + │ + └── 根据信任状态更新 UI + │ + ├── 信任 → 正常 UI + └── 受限 → 状态栏显示「受限模式」标识 +``` + +## 数据设计 + +### 存储方案 + +使用 `globalStorage`(全局持久化存储)记录每个目录的信任状态。 + +存储 key 格式:`workspace_trust_state:` 存储 value:`"trusted"` | `"restricted"` + +例如: + +``` +workspace_trust_state:/Users/mahang/Workspace/ide/core = "trusted" +workspace_trust_state:/tmp/suspicious-project = "restricted" +``` + +### 存储模块 + +使用 `STORAGE_NAMESPACE.GLOBAL_EXTENSIONS` 全局存储空间,以 key-value 形式持久化。 + +### 信任白名单插件 + +受限模式下仅允许加载以下插件(可通过配置扩展): + +- `vscode.theme-defaults` - 默认主题 +- `vscode.typescript-language-features` - TypeScript 语言服务 +- `common-extension` - 通用扩展 + +## 模块设计 + +### 新增模块:`workspace-trust` + +在 `packages/workspace-trust/` 下创建新模块: + +#### 文件结构 + +``` +packages/workspace-trust/ +├── package.json +├── src/ +│ ├── common/ +│ │ └── index.ts # 公共常量和类型定义 +│ └── browser/ +│ ├── index.ts # 模块入口,BrowserModule 定义 +│ ├── workspace-trust.service.ts # WorkspaceTrustService +│ ├── workspace-trust.contribution.ts # ClientAppContribution, 信任对话框 +│ └── workspace-trust-statusbar.contribution.ts # 状态栏「受限模式」标识 +``` + +#### `WorkspaceTrustService` 核心方法 + +| 方法 | 说明 | +| --- | --- | +| `getTrustState(workspacePath: string): WorkspaceTrustState \| undefined` | 获取指定工作区路径的信任状态 | +| `setTrustState(workspacePath: string, state: WorkspaceTrustState): Promise` | 设置并持久化信任状态 | +| `isRestricted(): boolean` | 当前工作区是否处于受限模式 | +| `showTrustDialog(): Promise` | 弹出信任选择对话框 | +| `ensureTrustDecided(): Promise` | 确保当前工作区已有信任决定(如没有则弹窗) | +| `getAllowedExtensionIds(): string[]` | 受限模式下允许加载的插件 ID 白名单 | + +#### `WorkspaceTrustContribution` + +实现 `ClientAppContribution` 接口: + +- `initialize()`: 检查当前工作区的信任状态,如果未决定则弹出信任对话框 + - 此方法在 `ExtensionClientAppContribution.initialize()` 之前执行(通过模块依赖保证) + +#### `WorkspaceTrustStatusbarContribution` + +- 在受限模式下,于状态栏右侧显示「受限模式」标识 +- 点击可跳转到设置页面解除受限模式 + +## 集成点 + +### 1. 信任检查时机 + +`WorkspaceTrustContribution.initialize()` 在 app 启动的 `initialize` 阶段执行。此时: + +1. 获取当前工作区路径(从 `AppConfig.workspaceDir`) +2. 查询 globalStorage 中是否已有信任状态 +3. 如果没有,弹出信任对话框,等待用户选择 +4. 将用户的决定写入 globalStorage + +### 2. 插件过滤时机 + +修改 `ExtensionServiceImpl.initExtensionMetaData()`,在获取到所有插件元数据后: + +1. 检查 `WorkspaceTrustService.isRestricted()` +2. 如果是受限模式,过滤 `extensionMetaDataArr`,仅保留白名单中的插件 + +### 3. 状态栏显示 + +`WorkspaceTrustStatusbarContribution` 在 `onStart()` 阶段: + +1. 检查 `WorkspaceTrustService.isRestricted()` +2. 如果是受限模式,在状态栏右侧添加「受限模式」元素 + +## 模块依赖 + +``` +workspace-trust → extension (依赖关系:workspace-trust 模块需要在 extension 之前初始化) +workspace-trust → workspace (获取工作区路径) +``` + +## 后续扩展 + +- 在设置页面添加「管理工作区信任」入口,允许用户修改已有工作区的信任状态 +- 支持更多类型的白名单插件配置 +- 受限模式下禁用终端、调试等可能存在安全风险的功能 diff --git a/packages/components/src/select/index.tsx b/packages/components/src/select/index.tsx index 64a4f17ff8..66d46ab320 100644 --- a/packages/components/src/select/index.tsx +++ b/packages/components/src/select/index.tsx @@ -27,6 +27,10 @@ export interface ISelectProps { disabled?: boolean; onChange?: (value: T) => void; onSearchChange?: (value: string) => void; + /** + * 搜索行为不采用默认的 filterOptions 进行筛选,由外部托管 + */ + externalSearchBehavior?: boolean; /** * 当鼠标划过时触发回调 * @param value 鼠标划过的是第几个 option @@ -290,6 +294,7 @@ export function Select({ description, notMatchWarning, onSearchChange, + externalSearchBehavior }: ISelectProps) { const [open, setOpen] = useState(false); const [searchInput, setSearchInput] = useState(''); @@ -297,6 +302,8 @@ export function Select({ const selectRef = React.useRef(null); const overlayRef = React.useRef(null); + externalSearchBehavior = externalSearchBehavior ?? !!onSearchChange + const handleToggleOpen = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -526,7 +533,7 @@ export function Select({ {showWarning &&
{notMatchWarning}
} {open && - !searchInput && + !(externalSearchBehavior && searchInput) && (isDataOptions(filteredOptions) || isDataOptionGroups(filteredOptions) ? ( { + const okText = localize('workspace.trust.exitRestricted.confirm.ok', 'Trust and Reload'); + const cancelText = localize('workspace.trust.exitRestricted.cancel', 'Cancel'); + + const msg = await this.commandService.executeCommand('dialog.open', { + message: localize( + 'workspace.trust.exitRestricted.confirm.message', + 'Are you sure you want to trust the authors of the files in this workspace and exit Restricted Mode?', + ), + type: MessageType.Info, + buttons: [okText, cancelText], + }); + + if (msg === okText) { + await this.workspaceTrustService.setTrustState(WorkspaceTrustState.Trusted); + this.clientApp.fireOnReload(true); + } + }, + }); + } + + onStart() { + // no-op + } +} diff --git a/packages/core-browser/src/workspace-trust/workspace-trust-setting-panel.module.less b/packages/core-browser/src/workspace-trust/workspace-trust-setting-panel.module.less new file mode 100644 index 0000000000..8c469a30aa --- /dev/null +++ b/packages/core-browser/src/workspace-trust/workspace-trust-setting-panel.module.less @@ -0,0 +1,37 @@ +.workspaceTrustPanel { + padding: 12px 18px; + + .description { + margin-bottom: 12px; + color: var(--foreground); + font-size: 13px; + line-height: 1.5; + } + + .button { + padding: 6px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + color: #fff; + transition: opacity 0.15s; + + &:hover:not(:disabled) { + opacity: 0.85; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .buttonTrust { + background-color: var(--button-background); + } + + .buttonRestrict { + background-color: var(--button-background); + } +} diff --git a/packages/core-browser/src/workspace-trust/workspace-trust-setting-panel.tsx b/packages/core-browser/src/workspace-trust/workspace-trust-setting-panel.tsx new file mode 100644 index 0000000000..046d666882 --- /dev/null +++ b/packages/core-browser/src/workspace-trust/workspace-trust-setting-panel.tsx @@ -0,0 +1,55 @@ +import React, { useCallback, useState } from 'react'; + +import { localize } from '@opensumi/ide-core-common'; + +import { IClientApp } from '../browser-module'; +import { useInjectable } from '../react-hooks/injectable-hooks'; + +import { WorkspaceTrustState } from './common'; +import styles from './workspace-trust-setting-panel.module.less'; +import { WorkspaceTrustService } from './workspace-trust.service'; + +export const WorkspaceTrustSettingPanel: React.FC<{ scope: number }> = () => { + const workspaceTrustService = useInjectable(WorkspaceTrustService); + const clientApp = useInjectable(IClientApp); + const [loading, setLoading] = useState(false); + + const isRestricted = workspaceTrustService.isRestricted(); + + const handleClick = useCallback(async () => { + setLoading(true); + if (isRestricted) { + await workspaceTrustService.setTrustState(WorkspaceTrustState.Trusted); + } else { + await workspaceTrustService.setTrustState(WorkspaceTrustState.Restricted); + } + clientApp.fireOnReload(true); + }, [isRestricted, workspaceTrustService, clientApp]); + + const buttonText = isRestricted + ? localize('workspace.trust.settings.trustButton', 'Trust Current Project') + : localize('workspace.trust.settings.restrictButton', 'Enter Restricted Mode'); + + const description = isRestricted + ? localize( + 'workspace.trust.settings.restrictedDesc', + '当前处于受限模式。当前 IDE 提供可以自动在此文件夹中执行文件的功能。点击以信任此工作区中文件的作者。', + ) + : localize( + 'workspace.trust.settings.trustedDesc', + '此工作区已被信任。点击以进入受限模式并禁用可能存在风险的功能。', + ); + + return ( +
+
{description}
+ +
+ ); +}; diff --git a/packages/core-browser/src/workspace-trust/workspace-trust-settings.contribution.ts b/packages/core-browser/src/workspace-trust/workspace-trust-settings.contribution.ts new file mode 100644 index 0000000000..2b0d5f9990 --- /dev/null +++ b/packages/core-browser/src/workspace-trust/workspace-trust-settings.contribution.ts @@ -0,0 +1,28 @@ +import { Autowired } from '@opensumi/di'; +import { localize } from '@opensumi/ide-core-common'; +import { Domain } from '@opensumi/ide-core-common/lib/di-helper'; + +import { ClientAppContribution } from '../common/common.define'; +import { IPreferenceSettingsService } from '../preferences/settings'; +import { getIcon } from '../style/icon/icon'; + +import { WorkspaceTrustSettingPanel } from './workspace-trust-setting-panel'; + +@Domain(ClientAppContribution) +export class WorkspaceTrustSettingsContribution implements ClientAppContribution { + @Autowired(IPreferenceSettingsService) + private readonly preferenceSettingsService: IPreferenceSettingsService; + + onStart() { + this.preferenceSettingsService.registerSettingGroup({ + id: 'workspace-trust', + title: localize('workspace.trust.settings.group.title'), + iconClass: getIcon('shield'), + }); + + this.preferenceSettingsService.registerSettingSection('workspace-trust', { + title: localize('workspace.trust.settings.section.title'), + component: WorkspaceTrustSettingPanel, + }); + } +} diff --git a/packages/core-browser/src/workspace-trust/workspace-trust-statusbar.contribution.ts b/packages/core-browser/src/workspace-trust/workspace-trust-statusbar.contribution.ts new file mode 100644 index 0000000000..d948eaca82 --- /dev/null +++ b/packages/core-browser/src/workspace-trust/workspace-trust-statusbar.contribution.ts @@ -0,0 +1,38 @@ +import { Autowired } from '@opensumi/di'; +import { localize, runWhenIdle } from '@opensumi/ide-core-common'; +import { Domain } from '@opensumi/ide-core-common/lib/di-helper'; + +import { ClientAppContribution } from '../common/common.define'; +import { IStatusBarService, StatusBarAlignment } from '../services/status-bar-service'; + +import { WORKSPACE_TRUST_EXIT_RESTRICTED_COMMAND } from './workspace-trust-command.contribution'; +import { WorkspaceTrustService } from './workspace-trust.service'; + +const RESTRICTED_MODE_STATUSBAR_ID = 'workspace-trust.restricted-mode'; + +@Domain(ClientAppContribution) +export class WorkspaceTrustStatusBarContribution implements ClientAppContribution { + @Autowired(WorkspaceTrustService) + private readonly workspaceTrustService: WorkspaceTrustService; + + @Autowired(IStatusBarService) + private readonly statusBarService: IStatusBarService; + + onStart() { + runWhenIdle(async () => { + await this.workspaceTrustService.whenTrustDecided(); + if (this.workspaceTrustService.isRestricted()) { + this.statusBarService.addElement(RESTRICTED_MODE_STATUSBAR_ID, { + text: `$(shield) ${localize('workspace.trust.statusbar.restricted', 'Restricted Mode')}`, + alignment: StatusBarAlignment.RIGHT, + tooltip: localize( + 'workspace.trust.statusbar.restricted.tooltip', + 'Restricted Mode - Some features are disabled for security', + ), + className: 'workspace-trust-restricted-status', + command: WORKSPACE_TRUST_EXIT_RESTRICTED_COMMAND.id, + }); + } + }); + } +} diff --git a/packages/core-browser/src/workspace-trust/workspace-trust.contribution.ts b/packages/core-browser/src/workspace-trust/workspace-trust.contribution.ts new file mode 100644 index 0000000000..f3fbd3bc8f --- /dev/null +++ b/packages/core-browser/src/workspace-trust/workspace-trust.contribution.ts @@ -0,0 +1,44 @@ +import { Autowired } from '@opensumi/di'; +import { ILogger } from '@opensumi/ide-core-common'; +import { Domain } from '@opensumi/ide-core-common/lib/di-helper'; + +import { ClientAppContribution } from '../common/common.define'; +import { AppConfig } from '../react-providers/config-provider'; + +import { WorkspaceTrustService } from './workspace-trust.service'; + +@Domain(ClientAppContribution) +export class WorkspaceTrustContribution implements ClientAppContribution { + @Autowired(WorkspaceTrustService) + private readonly workspaceTrustService: WorkspaceTrustService; + + @Autowired(AppConfig) + private readonly appConfig: AppConfig; + + @Autowired(ILogger) + private readonly logger: ILogger; + + async onStart() { + const workspacePath = this.appConfig.workspaceDir; + if (!workspacePath) { + this.logger.log('[workspace-trust] No workspace directory, skipping trust check'); + return; + } + + this.logger.log(`[workspace-trust] Initializing trust check for: ${workspacePath}`); + + // Initialize storage and load existing trust state + await this.workspaceTrustService.initialize(workspacePath); + + // If no saved trust state, show dialog + if (this.workspaceTrustService.getTrustState() === 'undecided') { + await this.workspaceTrustService.ensureTrustDecided(); + } + + if (this.workspaceTrustService.isRestricted()) { + this.logger.log('[workspace-trust] Workspace is in restricted mode'); + } else { + this.logger.log('[workspace-trust] Workspace is trusted'); + } + } +} diff --git a/packages/core-browser/src/workspace-trust/workspace-trust.service.ts b/packages/core-browser/src/workspace-trust/workspace-trust.service.ts new file mode 100644 index 0000000000..d9fce3c05a --- /dev/null +++ b/packages/core-browser/src/workspace-trust/workspace-trust.service.ts @@ -0,0 +1,143 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { + CommandService, + Deferred, + IStorage, + MessageType, + STORAGE_NAMESPACE, + StorageProvider, + localize, +} from '@opensumi/ide-core-common'; + +import { OpenDialogArgs } from '../common/common.command'; + +import { WORKSPACE_TRUST_STORAGE_KEY, WorkspaceTrustState } from './common'; + +/** + * Workspace trust service + * Manages trust state for workspace directories and controls restricted mode + */ +@Injectable() +export class WorkspaceTrustService { + @Autowired(StorageProvider) + private readonly storageProvider: StorageProvider; + + @Autowired(CommandService) + private readonly commandService: CommandService; + + private trustStorage: IStorage; + private currentTrustState: WorkspaceTrustState = WorkspaceTrustState.Undecided; + private trustDecidedDeferred = new Deferred(); + private _workspacePath: string | undefined; + + /** + * Initialize the trust service and load current workspace trust state + */ + async initialize(workspacePath: string): Promise { + this._workspacePath = workspacePath; + this.trustStorage = await this.storageProvider(STORAGE_NAMESPACE.GLOBAL_EXTENSIONS); + await this.trustStorage.whenReady; + + const savedState = this.getTrustStateFromStorage(workspacePath); + if (savedState) { + this.currentTrustState = savedState; + this.trustDecidedDeferred.resolve(); + } + } + + /** + * Get the trust state for a workspace path from storage + */ + private getTrustStateFromStorage(workspacePath: string): WorkspaceTrustState | undefined { + const key = `${WORKSPACE_TRUST_STORAGE_KEY}:${workspacePath}`; + const value = this.trustStorage.get(key); + if (value === WorkspaceTrustState.Trusted || value === WorkspaceTrustState.Restricted) { + return value as WorkspaceTrustState; + } + return undefined; + } + + /** + * Save trust state to storage + */ + private async saveTrustState(workspacePath: string, state: WorkspaceTrustState): Promise { + const key = `${WORKSPACE_TRUST_STORAGE_KEY}:${workspacePath}`; + await this.trustStorage.set(key, state); + } + + /** + * Set the trust state for the current workspace + */ + async setTrustState(state: WorkspaceTrustState): Promise { + if (!this._workspacePath) { + return; + } + this.currentTrustState = state; + await this.saveTrustState(this._workspacePath, state); + this.trustDecidedDeferred.resolve(); + } + + /** + * Get current workspace trust state + */ + getTrustState(): WorkspaceTrustState { + return this.currentTrustState; + } + + /** + * Check if current workspace is in restricted mode + */ + isRestricted(): boolean { + return this.currentTrustState === WorkspaceTrustState.Restricted; + } + + /** + * Check if current workspace is trusted + */ + isTrusted(): boolean { + return this.currentTrustState === WorkspaceTrustState.Trusted; + } + + /** + * Get the promise that resolves when trust decision is made + */ + whenTrustDecided(): Promise { + return this.trustDecidedDeferred.promise; + } + + /** + * Show trust dialog and wait for user decision + */ + async showTrustDialog(): Promise { + const trustLabel = localize('workspace.trust.dialog.button.trust', 'Yes, I trust the authors'); + const restrictedLabel = localize('workspace.trust.dialog.button.restricted', 'Restricted Mode'); + + await this.commandService.waitCommandHandlerRegistered('dialog.open'); + const result = await this.commandService.executeCommand('dialog.open', { + message: `${localize('workspace.trust.dialog.title', '是否信任此文件夹中的文件的作者?')}\n\n${localize( + 'workspace.trust.dialog.message', + '当前 IDE 提供可以自动在此文件夹中执行文件的功能。\n\n如果不信任这些文件的作者,则建议继续使用受限模式,因为这些文件可能是恶意文件。', + )}`, + type: MessageType.Info, + buttons: [trustLabel, restrictedLabel], + closable: false, + } as OpenDialogArgs); + + if (result === trustLabel) { + await this.setTrustState(WorkspaceTrustState.Trusted); + return WorkspaceTrustState.Trusted; + } else { + await this.setTrustState(WorkspaceTrustState.Restricted); + return WorkspaceTrustState.Restricted; + } + } + + /** + * Ensure trust decision is made - shows dialog if not yet decided + */ + async ensureTrustDecided(): Promise { + if (this.currentTrustState === WorkspaceTrustState.Undecided) { + await this.showTrustDialog(); + } + } +} diff --git a/packages/core-common/src/command.ts b/packages/core-common/src/command.ts index bfa9a61a71..de865eb8d8 100644 --- a/packages/core-common/src/command.ts +++ b/packages/core-common/src/command.ts @@ -153,6 +153,13 @@ export interface CommandService { tryExecuteCommand(commandId: string, ...args: any[]): Promise; onWillExecuteCommand: Event; onDidExecuteCommand: Event; + /** + * Wait until a command handler is registered for the given command id. + * Resolves immediately if a handler already exists. + * @param commandId command id to wait for + * @param timeoutMs max wait time in milliseconds, default 30000 + */ + waitCommandHandlerRegistered(commandId: string, timeoutMs?: number): Promise; } /** @@ -243,6 +250,13 @@ interface CoreCommandRegistry { * @param args */ isPermittedCommand(commandId: string, extensionInfo: IExtensionInfo, ...args: any[]): boolean; + /** + * Wait until a command handler is registered for the given command id. + * Resolves immediately if a handler already exists. + * @param commandId command id to wait for + * @param timeoutMs max wait time in milliseconds, default 30000 + */ + waitCommandHandlerRegistered(commandId: string, timeoutMs?: number): Promise; } export interface CommandRegistry extends CoreCommandRegistry { @@ -272,6 +286,9 @@ export class CoreCommandRegistryImpl implements CoreCommandRegistry { private readonly logger = getDebugLogger(); + // Emitters waiting for a handler to be registered for a specific command + private readonly _handlerWaitingEmitters = new Map>(); + /** * 命令执行方法 * @param commandId 命令执行方法 @@ -407,6 +424,15 @@ export class CoreCommandRegistryImpl implements CoreCommandRegistry { this._handlers[commandId] = handlers = []; } handlers.unshift(handler); + + // Notify any waiters that a handler is now registered + const waitingEmitter = this._handlerWaitingEmitters.get(commandId); + if (waitingEmitter) { + waitingEmitter.fire(); + waitingEmitter.dispose(); + this._handlerWaitingEmitters.delete(commandId); + } + return { dispose: () => { const idx = handlers.indexOf(handler); @@ -512,6 +538,31 @@ export class CoreCommandRegistryImpl implements CoreCommandRegistry { ); } + waitCommandHandlerRegistered(commandId: string, timeoutMs = 30000): Promise { + // Resolve immediately if a handler already exists + if (this.getActiveHandler(commandId)) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let waitingEmitter = this._handlerWaitingEmitters.get(commandId); + if (!waitingEmitter) { + waitingEmitter = new Emitter(); + this._handlerWaitingEmitters.set(commandId, waitingEmitter); + } + + const timer = setTimeout(() => { + listener.dispose(); + reject(new Error(`Timeout waiting for command handler registration: ${commandId}`)); + }, timeoutMs); + + const listener = waitingEmitter.event(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + /** * 获取可切换的命令处理函数 * @param commandId 命令 id @@ -727,4 +778,8 @@ export class CommandServiceImpl implements CommandService { getDebugLogger().warn(err); } } + + waitCommandHandlerRegistered(commandId: string, timeoutMs?: number): Promise { + return this.commandRegistry.waitCommandHandlerRegistered(commandId, timeoutMs); + } } diff --git a/packages/extension/src/browser/allowed-extension.service.ts b/packages/extension/src/browser/allowed-extension.service.ts new file mode 100644 index 0000000000..bd18e921ed --- /dev/null +++ b/packages/extension/src/browser/allowed-extension.service.ts @@ -0,0 +1,61 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { WorkspaceTrustService } from '@opensumi/ide-core-browser'; +import { ContributionProvider } from '@opensumi/ide-core-common'; + +/** + * Base allowed extension IDs in restricted mode + */ +export const DEFAULT_ALLOWED_EXTENSION_IDS = ['vscode.theme-defaults', 'vscode.typescript-language-features']; + +/** + * Contribution token for allowed extensions registration + */ +export const AllowedExtensionsContribution = Symbol('AllowedExtensionsContribution'); + +export interface IAllowedExtensionsContribution { + /** + * Return additional extension IDs allowed in restricted mode + */ + getAllowedExtensionIds(): string[]; +} + +@Injectable() +export class AllowedExtensionService { + @Autowired(WorkspaceTrustService) + private readonly workspaceTrustService: WorkspaceTrustService; + + @Autowired(AllowedExtensionsContribution) + private readonly contributions: ContributionProvider; + + /** + * Get all allowed extension IDs (base + contributions) + */ + getAllowedExtensionIds(): string[] { + const allIds = new Set(DEFAULT_ALLOWED_EXTENSION_IDS); + const contribs = this.contributions.getContributions(); + for (const c of contribs) { + for (const id of c.getAllowedExtensionIds()) { + allIds.add(id); + } + } + return Array.from(allIds); + } + + /** + * Filter extension metadata to only include allowed extensions in restricted mode + */ + filterExtensions(extensions: T[]): T[] { + if (!this.workspaceTrustService.isRestricted()) { + return extensions; + } + const allowedIds = new Set(this.getAllowedExtensionIds()); + return extensions.filter((ext) => allowedIds.has(ext.id)); + } + + /** + * Wait for trust decision to be made + */ + waitTrustDecided(): Promise { + return this.workspaceTrustService.whenTrustDecided(); + } +} diff --git a/packages/extension/src/browser/extension.service.ts b/packages/extension/src/browser/extension.service.ts index 4eb2e089c3..8ce34d6b0b 100644 --- a/packages/extension/src/browser/extension.service.ts +++ b/packages/extension/src/browser/extension.service.ts @@ -57,6 +57,7 @@ import { import { MainThreadAPIIdentifier } from '../common/vscode'; import { ActivationEventServiceImpl } from './activation.service'; +import { AllowedExtensionService } from './allowed-extension.service'; import { Extension } from './extension'; import { SumiContributionsService, SumiContributionsServiceToken } from './sumi/contributes'; import { @@ -148,6 +149,9 @@ export class ExtensionServiceImpl extends WithEventBus implements ExtensionServi @Autowired(IFileServiceClient) protected fileServiceClient: IFileServiceClient; + @Autowired(AllowedExtensionService) + private readonly allowedExtensionService: AllowedExtensionService; + constructor() { super(); @@ -237,8 +241,30 @@ export class ExtensionServiceImpl extends WithEventBus implements ExtensionServi public async activate(): Promise { await this.initExtensionMetaData(); - await this.initExtensionInstanceData(); + + // Split extensions into allowed (can init before trust decision) and others (wait for trust) + const allowedIds = new Set(this.allowedExtensionService.getAllowedExtensionIds()); + const allowedExtensions = this.extensionMetaDataArr.filter((ext) => allowedIds.has(ext.id)); + const pendingExtensions = this.extensionMetaDataArr.filter((ext) => !allowedIds.has(ext.id)); + + // Initialize allowed extensions immediately + await this.initExtensionInstanceData(allowedExtensions); await this.runEagerExtensionsContributes(); + + if (pendingExtensions.length > 0) { + // Wait for trust decision, then initialize the rest + await this.allowedExtensionService.waitTrustDecided(); + const allowedPending = this.allowedExtensionService.filterExtensions(pendingExtensions); + if (allowedPending.length > 0) { + await this.initExtensionInstanceData(allowedPending); + } + // Run eager extension contributes once all extensions are initialized + await this.runEagerExtensionsContributes(); + } + + // Final extension list after trust filtering + this.extensionMetaDataArr = this.allowedExtensionService.filterExtensions(this.extensionMetaDataArr); + // update nls config by extensions await this.setupExtensionNLSConfig(); @@ -301,8 +327,9 @@ export class ExtensionServiceImpl extends WithEventBus implements ExtensionServi /** * 初始化插件实例数据 */ - private async initExtensionInstanceData() { - for (const extensionMetaData of this.extensionMetaDataArr) { + private async initExtensionInstanceData(extensions?: IExtensionMetaData[]) { + const metaDataArr = extensions ?? this.extensionMetaDataArr; + for (const extensionMetaData of metaDataArr) { const isBuiltin = this.extensionInstanceManageService.checkIsBuiltin(extensionMetaData); const isDevelopment = this.extensionInstanceManageService.checkIsDevelopment(extensionMetaData); const extension = await this.extensionInstanceManageService.createExtensionInstance( diff --git a/packages/extension/src/browser/index.ts b/packages/extension/src/browser/index.ts index f049d13e79..30162b524c 100644 --- a/packages/extension/src/browser/index.ts +++ b/packages/extension/src/browser/index.ts @@ -1,5 +1,5 @@ import { Injectable, Provider } from '@opensumi/di'; -import { BrowserModule } from '@opensumi/ide-core-browser'; +import { BrowserModule, createContributionProvider } from '@opensumi/ide-core-browser'; import { DebugSessionContributionRegistry } from '@opensumi/ide-debug/lib/browser/debug-session-contribution'; import { IDebugServer } from '@opensumi/ide-debug/lib/common'; import { FileSearchServicePath } from '@opensumi/ide-file-search/lib/common'; @@ -26,6 +26,7 @@ import { } from '../common/main.thread.extender'; import { ActivationEventServiceImpl } from './activation.service'; +import { AllowedExtensionService, AllowedExtensionsContribution } from './allowed-extension.service'; import { ExtCommandManagementImpl as ExtCommandManagementImpl } from './extension-command-management'; import { ExtInstanceManagementService } from './extension-instance-management'; import { ExtensionManagementService } from './extension-management.service'; @@ -42,8 +43,16 @@ import { VSCodeContributesService, VSCodeContributesServiceToken } from './vscod @Injectable() export class ExtensionModule extends BrowserModule { - contributionProvider = [RequireInterceptorContribution, MainThreadExtenderContribution]; + contributionProvider = [ + RequireInterceptorContribution, + MainThreadExtenderContribution, + AllowedExtensionsContribution, + ]; providers: Provider[] = [ + { + token: AllowedExtensionService, + useClass: AllowedExtensionService, + }, { token: ExtensionService, useClass: ExtensionServiceImpl, @@ -120,3 +129,10 @@ export class ExtensionModule extends BrowserModule { }, ]; } + +export { + AllowedExtensionService, + AllowedExtensionsContribution, + IAllowedExtensionsContribution, + DEFAULT_ALLOWED_EXTENSION_IDS, +} from './allowed-extension.service'; diff --git a/packages/extension/src/browser/vscode/api/main.thread.language.ts b/packages/extension/src/browser/vscode/api/main.thread.language.ts index dc70f85b47..fa7959028c 100644 --- a/packages/extension/src/browser/vscode/api/main.thread.language.ts +++ b/packages/extension/src/browser/vscode/api/main.thread.language.ts @@ -52,6 +52,7 @@ import { IdentifiableInlineCompletions, IdentifiableInlineEdit, MonacoModelIdentifier, + RangeSuggestDataDto, testGlob, } from '../../../common/vscode'; import { IDocumentFilterDto, fromLanguageSelector } from '../../../common/vscode/converter'; @@ -270,6 +271,22 @@ export class MainThreadLanguages implements IMainThreadLanguages { const dtoRange = data[ISuggestDataDtoField.range]; + let targetRange: IRange | { insert: IRange; replace: IRange } = defaultRange; + + if (dtoRange) { + if (Array.isArray(dtoRange)) { + targetRange = MonacoRange.lift({ + startLineNumber: dtoRange[0], + startColumn: dtoRange[1], + endLineNumber: dtoRange[2], + endColumn: dtoRange[3], + }) + } else { + targetRange = dtoRange; + } + } + + return { label, kind: data[ISuggestDataDtoField.kind] ?? modes.CompletionItemKind.Property, @@ -280,15 +297,7 @@ export class MainThreadLanguages implements IMainThreadLanguages { filterText: data[ISuggestDataDtoField.filterText], preselect: data[ISuggestDataDtoField.preselect], insertText: data[ISuggestDataDtoField.insertText] ?? (typeof label === 'string' ? label : label.label), - range: - Array.isArray(dtoRange) && dtoRange.length === 4 - ? MonacoRange.lift({ - startLineNumber: dtoRange[0], - startColumn: dtoRange[1], - endLineNumber: dtoRange[2], - endColumn: dtoRange[3], - }) - : defaultRange, + range: targetRange, insertTextRules: data[ISuggestDataDtoField.insertTextRules], commitCharacters: data[ISuggestDataDtoField.commitCharacters], additionalTextEdits: data[ISuggestDataDtoField.additionalTextEdits], diff --git a/packages/file-tree-next/src/browser/dialog/file-dialog.view.tsx b/packages/file-tree-next/src/browser/dialog/file-dialog.view.tsx index eb7b9c2e04..2c28bd6c24 100644 --- a/packages/file-tree-next/src/browser/dialog/file-dialog.view.tsx +++ b/packages/file-tree-next/src/browser/dialog/file-dialog.view.tsx @@ -288,6 +288,7 @@ export const FileDialog = ({ searchPlaceholder={selectPath} value={selectPath} showSearch={showFilePathSearch} + externalSearchBehavior={true} > {directoryList.map((item, idx) => (