feat(coding-agent): add extension project trust decisions
This commit is contained in:
@@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
|
||||
import { createEventBus, type EventBus } from "./event-bus.ts";
|
||||
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
|
||||
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
|
||||
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
|
||||
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
|
||||
import type { PromptTemplate } from "./prompt-templates.ts";
|
||||
import { loadPromptTemplates } from "./prompt-templates.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
@@ -25,6 +25,10 @@ export interface ResourceExtensionPaths {
|
||||
themePaths?: Array<{ path: string; metadata: PathMetadata }>;
|
||||
}
|
||||
|
||||
export interface ResourceLoaderReloadOptions {
|
||||
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ResourceLoader {
|
||||
getExtensions(): LoadExtensionsResult;
|
||||
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
@@ -34,7 +38,7 @@ export interface ResourceLoader {
|
||||
getSystemPrompt(): string | undefined;
|
||||
getAppendSystemPrompt(): string[];
|
||||
extendResources(paths: ResourceExtensionPaths): void;
|
||||
reload(): Promise<void>;
|
||||
reload(options?: ResourceLoaderReloadOptions): Promise<void>;
|
||||
}
|
||||
|
||||
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
|
||||
@@ -321,7 +325,19 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
|
||||
let preTrustExtensions: LoadExtensionsResult | undefined;
|
||||
if (options?.resolveProjectTrust) {
|
||||
// Force untrusted project settings for the bootstrap pass. This keeps project-local
|
||||
// extensions/packages out while still loading user/global and temporary CLI extensions.
|
||||
this.settingsManager.setProjectTrusted(false);
|
||||
await this.settingsManager.reload();
|
||||
preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true });
|
||||
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
|
||||
this.settingsManager.setProjectTrusted(projectTrusted);
|
||||
}
|
||||
|
||||
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.
|
||||
await this.settingsManager.reload();
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
@@ -334,9 +350,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
this.extensionThemeSourceInfos = new Map();
|
||||
|
||||
// Helper to extract enabled paths and store metadata
|
||||
const getEnabledResources = (
|
||||
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
|
||||
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
|
||||
const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {
|
||||
for (const r of resources) {
|
||||
if (!metadataByPath.has(r.path)) {
|
||||
metadataByPath.set(r.path, r.metadata);
|
||||
@@ -345,37 +359,14 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
return resources.filter((r) => r.enabled);
|
||||
};
|
||||
|
||||
const getEnabledPaths = (
|
||||
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
|
||||
): string[] => getEnabledResources(resources).map((r) => r.path);
|
||||
const getEnabledPaths = (resources: ResolvedResource[]): string[] =>
|
||||
getEnabledResources(resources).map((r) => r.path);
|
||||
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
|
||||
const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
|
||||
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
|
||||
const enabledThemes = getEnabledPaths(resolvedPaths.themes);
|
||||
|
||||
const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => {
|
||||
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
|
||||
return resource.path;
|
||||
}
|
||||
try {
|
||||
const stats = statSync(resource.path);
|
||||
if (!stats.isDirectory()) {
|
||||
return resource.path;
|
||||
}
|
||||
} catch {
|
||||
return resource.path;
|
||||
}
|
||||
const skillFile = join(resource.path, "SKILL.md");
|
||||
if (existsSync(skillFile)) {
|
||||
if (!metadataByPath.has(skillFile)) {
|
||||
metadataByPath.set(skillFile, resource.metadata);
|
||||
}
|
||||
return skillFile;
|
||||
}
|
||||
return resource.path;
|
||||
};
|
||||
|
||||
const enabledSkills = enabledSkillResources.map(mapSkillPath);
|
||||
const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
|
||||
|
||||
// Add CLI paths metadata
|
||||
for (const r of cliExtensionPaths.extensions) {
|
||||
@@ -398,18 +389,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
|
||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||
for (const conflict of conflicts) {
|
||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||
}
|
||||
|
||||
const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);
|
||||
for (const p of this.additionalExtensionPaths) {
|
||||
if (isLocalPath(p)) {
|
||||
const resolved = this.resolveResourcePath(p);
|
||||
@@ -497,6 +477,115 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
: baseAppend;
|
||||
}
|
||||
|
||||
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
temporary: true,
|
||||
});
|
||||
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
|
||||
const extensionPaths = this.noExtensions
|
||||
? cliEnabledExtensions
|
||||
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
if (!options.includeInlineFactories) {
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
private resolveExtensionLoadPath(path: string): string {
|
||||
return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
|
||||
}
|
||||
|
||||
private async loadFinalExtensionSet(
|
||||
extensionPaths: string[],
|
||||
preTrustExtensions: LoadExtensionsResult | undefined,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
if (!preTrustExtensions) {
|
||||
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
const preloadedByPath = new Map(
|
||||
preTrustExtensions.extensions
|
||||
.filter((extension) => !extension.path.startsWith("<inline:"))
|
||||
.map((extension) => [extension.resolvedPath, extension]),
|
||||
);
|
||||
const failedPreloadPaths = new Set(
|
||||
preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)),
|
||||
);
|
||||
const remainingPaths = extensionPaths.filter((path) => {
|
||||
const resolvedPath = this.resolveExtensionLoadPath(path);
|
||||
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
|
||||
});
|
||||
const remainingExtensions = await loadExtensions(
|
||||
remainingPaths,
|
||||
this.cwd,
|
||||
this.eventBus,
|
||||
preTrustExtensions.runtime,
|
||||
);
|
||||
const loadedByPath = new Map(preloadedByPath);
|
||||
for (const extension of remainingExtensions.extensions) {
|
||||
loadedByPath.set(extension.resolvedPath, extension);
|
||||
}
|
||||
|
||||
const inlineExtensions = preTrustExtensions.extensions.filter((extension) =>
|
||||
extension.path.startsWith("<inline:"),
|
||||
);
|
||||
const orderedExtensions = extensionPaths
|
||||
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
|
||||
.filter((extension): extension is Extension => extension !== undefined);
|
||||
orderedExtensions.push(...inlineExtensions);
|
||||
|
||||
const extensionsResult: LoadExtensionsResult = {
|
||||
extensions: orderedExtensions,
|
||||
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
|
||||
runtime: preTrustExtensions.runtime,
|
||||
};
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void {
|
||||
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
|
||||
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
|
||||
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
|
||||
for (const conflict of conflicts) {
|
||||
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
|
||||
}
|
||||
}
|
||||
|
||||
private mapSkillPath(resource: ResolvedResource, metadataByPath: Map<string, PathMetadata>): string {
|
||||
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
|
||||
return resource.path;
|
||||
}
|
||||
try {
|
||||
const stats = statSync(resource.path);
|
||||
if (!stats.isDirectory()) {
|
||||
return resource.path;
|
||||
}
|
||||
} catch {
|
||||
return resource.path;
|
||||
}
|
||||
const skillFile = join(resource.path, "SKILL.md");
|
||||
if (existsSync(skillFile)) {
|
||||
if (!metadataByPath.has(skillFile)) {
|
||||
metadataByPath.set(skillFile, resource.metadata);
|
||||
}
|
||||
return skillFile;
|
||||
}
|
||||
return resource.path;
|
||||
}
|
||||
|
||||
private normalizeExtensionPaths(
|
||||
entries: Array<{ path: string; metadata: PathMetadata }>,
|
||||
): Array<{ path: string; metadata: PathMetadata }> {
|
||||
|
||||
Reference in New Issue
Block a user