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 }> {
|
||||
|
||||
@@ -98,6 +98,10 @@ export type {
|
||||
LsToolCallEvent,
|
||||
MessageRenderer,
|
||||
MessageRenderOptions,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
ProviderConfig,
|
||||
ProviderModelConfig,
|
||||
ReadToolCallEvent,
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
|
||||
import { AuthStorage } from "./core/auth-storage.ts";
|
||||
import { exportFromFile } from "./core/export-html/index.ts";
|
||||
import type { ExtensionFactory } from "./core/extensions/types.ts";
|
||||
import { emitProjectTrustEvent } from "./core/extensions/runner.ts";
|
||||
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
|
||||
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
|
||||
import { KeybindingsManager } from "./core/keybindings.ts";
|
||||
import type { ModelRegistry } from "./core/model-registry.ts";
|
||||
@@ -43,6 +44,7 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
|
||||
@@ -437,24 +439,35 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
|
||||
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
|
||||
}
|
||||
|
||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||
ui.clear();
|
||||
ui.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
async function showStartupSelector<T>(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = (result: T | undefined) => {
|
||||
const finish = async (result: T | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
@@ -462,8 +475,8 @@ async function showStartupSelector<T>(
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
(option) => finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => finish(undefined),
|
||||
(option) => void finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => void finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
@@ -472,6 +485,41 @@ async function showStartupSelector<T>(
|
||||
});
|
||||
}
|
||||
|
||||
async function showStartupInput(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
input.dispose();
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const input = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => void finish(value),
|
||||
() => void finish(undefined),
|
||||
{
|
||||
tui: ui,
|
||||
},
|
||||
);
|
||||
ui.addChild(input);
|
||||
ui.setFocus(input);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForMissingSessionCwd(
|
||||
issue: SessionCwdIssue,
|
||||
settingsManager: SettingsManager,
|
||||
@@ -487,20 +535,90 @@ interface ProjectTrustPromptResult {
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
|
||||
{ label: "Trust", value: { trusted: true, remember: true } },
|
||||
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
|
||||
{ label: "Do not trust", value: { trusted: false, remember: true } },
|
||||
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
|
||||
];
|
||||
|
||||
function formatProjectTrustPrompt(cwd: string): string {
|
||||
return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`;
|
||||
}
|
||||
|
||||
async function promptForProjectTrust(
|
||||
cwd: string,
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
return showStartupSelector(
|
||||
settingsManager,
|
||||
`Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`,
|
||||
[
|
||||
{ label: "Trust", value: { trusted: true, remember: true } },
|
||||
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
|
||||
{ label: "Do not trust", value: { trusted: false, remember: true } },
|
||||
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
|
||||
],
|
||||
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
|
||||
}
|
||||
|
||||
async function promptForProjectTrustWithContext(
|
||||
cwd: string,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
const selected = await ctx.ui.select(
|
||||
formatProjectTrustPrompt(cwd),
|
||||
PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label),
|
||||
);
|
||||
return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value;
|
||||
}
|
||||
|
||||
function createProjectTrustContext(options: {
|
||||
cwd: string;
|
||||
mode: AppMode;
|
||||
settingsManager: SettingsManager;
|
||||
hasUI: boolean;
|
||||
}): ProjectTrustContext {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
mode: options.mode === "interactive" ? "tui" : options.mode,
|
||||
hasUI: options.hasUI,
|
||||
ui: {
|
||||
select: async (title, selectOptions) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupSelector(
|
||||
options.settingsManager,
|
||||
title,
|
||||
selectOptions.map((option) => ({ label: option, value: option })),
|
||||
);
|
||||
},
|
||||
confirm: async (title, message) => {
|
||||
if (!options.hasUI) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
])) ?? false
|
||||
);
|
||||
},
|
||||
input: async (title, placeholder) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupInput(options.settingsManager, title, placeholder);
|
||||
},
|
||||
notify: (message, type = "info") => {
|
||||
if (options.mode !== "interactive") {
|
||||
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
|
||||
console.error(color(message));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectTrusted(options: {
|
||||
@@ -509,6 +627,9 @@ async function resolveProjectTrusted(options: {
|
||||
trustOverride?: boolean;
|
||||
appMode: AppMode;
|
||||
settingsManagerForPrompt: SettingsManager;
|
||||
extensionsResult?: LoadExtensionsResult;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
onExtensionError?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
@@ -517,10 +638,37 @@ async function resolveProjectTrusted(options: {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.extensionsResult && options.projectTrustContext) {
|
||||
const { result, errors } = await emitProjectTrustEvent(
|
||||
options.extensionsResult,
|
||||
{ type: "project_trust", cwd: options.cwd },
|
||||
options.projectTrustContext,
|
||||
);
|
||||
for (const error of errors) {
|
||||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, result.trusted);
|
||||
}
|
||||
return result.trusted;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = options.trustStore.get(options.cwd);
|
||||
if (decision !== null) {
|
||||
return decision;
|
||||
}
|
||||
if (options.projectTrustContext?.hasUI) {
|
||||
const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext);
|
||||
if (selected !== undefined) {
|
||||
if (selected.remember) {
|
||||
options.trustStore.set(options.cwd, selected.trusted);
|
||||
}
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (options.appMode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
@@ -651,13 +799,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
const autoTrustOnReloadCwd =
|
||||
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
|
||||
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
|
||||
const projectTrustedForSession = await resolveProjectTrusted({
|
||||
cwd: sessionCwd,
|
||||
trustStore,
|
||||
trustOverride: parsed.projectTrustOverride,
|
||||
appMode: trustPromptMode,
|
||||
settingsManagerForPrompt: startupSettingsManager,
|
||||
});
|
||||
const projectTrustByCwd = new Map<string, boolean>();
|
||||
|
||||
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
|
||||
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
|
||||
@@ -669,11 +811,17 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
agentDir,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
projectTrustContext,
|
||||
}) => {
|
||||
const projectTrusted =
|
||||
cwd === sessionManager.getCwd()
|
||||
? projectTrustedForSession
|
||||
: (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true));
|
||||
const isInitialRuntime = sessionStartEvent === undefined;
|
||||
const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
||||
const cachedProjectTrust = projectTrustByCwd.get(cwd);
|
||||
const hasTrustInputs = hasProjectTrustInputs(cwd);
|
||||
const shouldResolveProjectTrust =
|
||||
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs;
|
||||
const projectTrusted = shouldResolveProjectTrust
|
||||
? false
|
||||
: (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true));
|
||||
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
@@ -681,6 +829,31 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
authStorage,
|
||||
settingsManager: runtimeSettingsManager,
|
||||
extensionFlagValues: parsed.unknownFlags,
|
||||
resourceLoaderReloadOptions: shouldResolveProjectTrust
|
||||
? {
|
||||
resolveProjectTrust: async ({ extensionsResult }) => {
|
||||
const trusted = await resolveProjectTrusted({
|
||||
cwd,
|
||||
trustStore,
|
||||
trustOverride: parsed.projectTrustOverride,
|
||||
appMode: isInitialRuntime ? trustPromptMode : "print",
|
||||
settingsManagerForPrompt: startupSettingsManager,
|
||||
extensionsResult,
|
||||
projectTrustContext:
|
||||
projectTrustContext ??
|
||||
createProjectTrustContext({
|
||||
cwd,
|
||||
mode: isInitialRuntime ? trustPromptMode : appMode,
|
||||
settingsManager: startupSettingsManager,
|
||||
hasUI: isInitialRuntime && trustPromptMode === "interactive",
|
||||
}),
|
||||
onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
|
||||
});
|
||||
projectTrustByCwd.set(cwd, trusted);
|
||||
return trusted;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
resourceLoaderOptions: {
|
||||
additionalExtensionPaths: resolvedExtensionPaths,
|
||||
additionalSkillPaths: resolvedSkillPaths,
|
||||
@@ -698,6 +871,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
});
|
||||
const { settingsManager, modelRegistry, resourceLoader } = services;
|
||||
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
|
||||
...projectTrustDiagnostics,
|
||||
...services.diagnostics,
|
||||
...collectSettingsDiagnostics(settingsManager, "runtime creation"),
|
||||
...resourceLoader.getExtensions().errors.map(({ path, error }) => ({
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetOptions,
|
||||
ProjectTrustContext,
|
||||
} from "../../core/extensions/index.ts";
|
||||
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
|
||||
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
|
||||
@@ -1993,6 +1994,21 @@ export class InteractiveMode {
|
||||
/**
|
||||
* Create the ExtensionUIContext for extensions.
|
||||
*/
|
||||
private createProjectTrustContext(cwd: string): ProjectTrustContext {
|
||||
const ui = this.createExtensionUIContext();
|
||||
return {
|
||||
cwd,
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
ui: {
|
||||
select: ui.select,
|
||||
confirm: ui.confirm,
|
||||
input: ui.input,
|
||||
notify: ui.notify,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createExtensionUIContext(): ExtensionUIContext {
|
||||
return {
|
||||
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
|
||||
@@ -4569,6 +4585,7 @@ export class InteractiveMode {
|
||||
try {
|
||||
const result = await this.runtimeHost.switchSession(sessionPath, {
|
||||
withSession: options?.withSession,
|
||||
projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd),
|
||||
});
|
||||
if (result.cancelled) {
|
||||
return result;
|
||||
@@ -4586,6 +4603,7 @@ export class InteractiveMode {
|
||||
const result = await this.runtimeHost.switchSession(sessionPath, {
|
||||
cwdOverride: selectedCwd,
|
||||
withSession: options?.withSession,
|
||||
projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd),
|
||||
});
|
||||
if (result.cancelled) {
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user