feat(coding-agent): add extension project trust decisions
This commit is contained in:
@@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path";
|
||||
import { resolvePath } from "../utils/paths.ts";
|
||||
import type { AgentSession } from "./agent-session.ts";
|
||||
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
|
||||
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
|
||||
import type {
|
||||
ProjectTrustContext,
|
||||
ReplacedSessionContext,
|
||||
SessionShutdownEvent,
|
||||
SessionStartEvent,
|
||||
} from "./extensions/index.ts";
|
||||
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
|
||||
import type { CreateAgentSessionResult } from "./sdk.ts";
|
||||
import { assertSessionCwdExists } from "./session-cwd.ts";
|
||||
@@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: {
|
||||
agentDir: string;
|
||||
sessionManager: SessionManager;
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
}) => Promise<CreateAgentSessionRuntimeResult>;
|
||||
|
||||
/**
|
||||
@@ -186,7 +192,11 @@ export class AgentSessionRuntime {
|
||||
|
||||
async switchSession(
|
||||
sessionPath: string,
|
||||
options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
|
||||
options?: {
|
||||
cwdOverride?: string;
|
||||
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
|
||||
projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
|
||||
},
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
|
||||
if (beforeResult.cancelled) {
|
||||
@@ -203,6 +213,7 @@ export class AgentSessionRuntime {
|
||||
agentDir: this.services.agentDir,
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
|
||||
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
|
||||
}),
|
||||
);
|
||||
await this.finishSessionReplacement(options?.withSession);
|
||||
|
||||
@@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts";
|
||||
import { AuthStorage } from "./auth-storage.ts";
|
||||
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
|
||||
import { ModelRegistry } from "./model-registry.ts";
|
||||
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts";
|
||||
import {
|
||||
DefaultResourceLoader,
|
||||
type DefaultResourceLoaderOptions,
|
||||
type ResourceLoader,
|
||||
type ResourceLoaderReloadOptions,
|
||||
} from "./resource-loader.ts";
|
||||
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
|
||||
import type { SessionManager } from "./session-manager.ts";
|
||||
import { SettingsManager } from "./settings-manager.ts";
|
||||
@@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions {
|
||||
modelRegistry?: ModelRegistry;
|
||||
extensionFlagValues?: Map<string, boolean | string>;
|
||||
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
|
||||
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +148,7 @@ export async function createAgentSessionServices(
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
await resourceLoader.reload(options.resourceLoaderReloadOptions);
|
||||
|
||||
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
||||
const extensionsResult = resourceLoader.getExtensions();
|
||||
|
||||
@@ -98,6 +98,10 @@ export type {
|
||||
MessageUpdateEvent,
|
||||
ModelSelectEvent,
|
||||
ModelSelectSource,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
// Provider Registration
|
||||
ProviderConfig,
|
||||
ProviderModelConfig,
|
||||
|
||||
@@ -410,15 +410,20 @@ export async function loadExtensionFromFactory(
|
||||
/**
|
||||
* Load extensions from paths.
|
||||
*/
|
||||
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
|
||||
export async function loadExtensions(
|
||||
paths: string[],
|
||||
cwd: string,
|
||||
eventBus?: EventBus,
|
||||
runtime?: ExtensionRuntime,
|
||||
): Promise<LoadExtensionsResult> {
|
||||
const extensions: Extension[] = [];
|
||||
const errors: Array<{ path: string; error: string }> = [];
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
const resolvedEventBus = eventBus ?? createEventBus();
|
||||
const runtime = createExtensionRuntime();
|
||||
const resolvedRuntime = runtime ?? createExtensionRuntime();
|
||||
|
||||
for (const extPath of paths) {
|
||||
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
|
||||
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
|
||||
|
||||
if (error) {
|
||||
errors.push({ path: extPath, error });
|
||||
@@ -433,7 +438,7 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
|
||||
return {
|
||||
extensions,
|
||||
errors,
|
||||
runtime,
|
||||
runtime: resolvedRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,13 @@ import type {
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
InputSource,
|
||||
LoadExtensionsResult,
|
||||
MessageEndEvent,
|
||||
MessageEndEventResult,
|
||||
MessageRenderer,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventResult,
|
||||
ProviderConfig,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
@@ -116,6 +120,7 @@ interface BeforeAgentStartCombinedResult {
|
||||
type RunnerEmitEvent = Exclude<
|
||||
ExtensionEvent,
|
||||
| ToolCallEvent
|
||||
| ProjectTrustEvent
|
||||
| ToolResultEvent
|
||||
| UserBashEvent
|
||||
| ContextEvent
|
||||
@@ -189,6 +194,35 @@ export async function emitSessionShutdownEvent(
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function emitProjectTrustEvent(
|
||||
extensionsResult: LoadExtensionsResult,
|
||||
event: ProjectTrustEvent,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> {
|
||||
const errors: ExtensionError[] = [];
|
||||
for (const ext of extensionsResult.extensions) {
|
||||
// A single extension may register multiple handlers for the same event.
|
||||
// The first project_trust handler that returns a decision wins.
|
||||
const handlers = ext.handlers.get("project_trust");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult;
|
||||
return { result: handlerResult, errors };
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
extensionPath: ext.path,
|
||||
event: event.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { errors };
|
||||
}
|
||||
|
||||
const noOpUIContext: ExtensionUIContext = {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
|
||||
@@ -495,9 +495,31 @@ export function defineTool<TParams extends TSchema, TDetails = unknown, TState =
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resource Events
|
||||
// Startup/Resource Events
|
||||
// ============================================================================
|
||||
|
||||
export interface ProjectTrustEvent {
|
||||
type: "project_trust";
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface ProjectTrustEventResult {
|
||||
trusted: boolean;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectTrustContext {
|
||||
cwd: string;
|
||||
mode: ExtensionMode;
|
||||
hasUI: boolean;
|
||||
ui: Pick<ExtensionUIContext, "select" | "confirm" | "input" | "notify">;
|
||||
}
|
||||
|
||||
export type ProjectTrustHandler = (
|
||||
event: ProjectTrustEvent,
|
||||
ctx: ProjectTrustContext,
|
||||
) => Promise<ProjectTrustEventResult> | ProjectTrustEventResult;
|
||||
|
||||
/** Fired after session_start to allow extensions to provide additional resource paths. */
|
||||
export interface ResourcesDiscoverEvent {
|
||||
type: "resources_discover";
|
||||
@@ -957,6 +979,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
|
||||
|
||||
/** Union of all event types */
|
||||
export type ExtensionEvent =
|
||||
| ProjectTrustEvent
|
||||
| ResourcesDiscoverEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
@@ -1095,6 +1118,7 @@ export interface ExtensionAPI {
|
||||
// Event Subscription
|
||||
// =========================================================================
|
||||
|
||||
on(event: "project_trust", handler: ProjectTrustHandler): void;
|
||||
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
|
||||
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
||||
on(
|
||||
|
||||
@@ -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