refactor(plugin-api): trim plugin API scope to grounded capabilities

Builds on marcus's plugin-api-completeness work. Narrows the new plugin
surface to capabilities that expose real, otherwise-unreachable pi-web
functionality, and drops invented/duplicative surfaces:

Kept:
- files.writeFile / deleteFile / moveFile (genuine workspace mutation,
  federated, path-safe)
- prompt.insertText / getText / getSelection (editor state access)

Dropped:
- attachments.* (insertFileReference/getAttachedFiles/removeFileReference):
  getAttachedFiles invented a structured-attachment notion pi-web does not
  have and duplicated prompt.getText() + a regex with a false email-safety
  claim; insert/removeFileReference were thin sugar over readFile +
  insertText that plugins can compose themselves.
- prompt.onPaste / onKeyDown: an incomplete two-event hook system shaped
  around a single use case, overlapping the editor's native image-paste
  handling. Deferred until a real editor event/hook surface is designed.
- prompt.focus: redundant and buggier duplicate of the existing
  focusPrompt() (silently no-ops when not on the chat view). Focus stays
  as focusPrompt().

Security fix:
- deleteWorkspaceFile now resolves the parent via realpath + ensureInside
  before lstat/unlink, closing a symlinked-parent-directory escape that
  allowed deleting files outside the workspace (write/move already did
  this). Final path component is still not resolved, so deleting a symlink
  removes the link, not its target. Adds a regression test.

Docs and the registry test mock updated to match the trimmed surface.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-14 23:16:37 +02:00
parent 7c915d7861
commit 3742bcc962
9 changed files with 34 additions and 223 deletions
+1 -68
View File
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginAttachments, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -1406,72 +1406,6 @@ export class PiWebApp extends LitElement {
if (sel.empty) return null;
return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) };
},
onPaste: (handler) => {
if (!this.promptEditor) {
console.warn("[pi-web] prompt.onPaste() called but prompt editor is not available. Handler will not be registered.");
return () => undefined;
}
const id = this.promptEditor.addPluginHandler("paste", handler);
return () => { this.promptEditor?.removePluginHandler(id); };
},
onKeyDown: (handler) => {
if (!this.promptEditor) {
console.warn("[pi-web] prompt.onKeyDown() called but prompt editor is not available. Handler will not be registered.");
return () => undefined;
}
const id = this.promptEditor.addPluginHandler("keydown", handler);
return () => { this.promptEditor?.removePluginHandler(id); };
},
focus: () => {
this.promptEditor?.focusInput();
},
};
}
private createPluginAttachments(): PluginAttachments {
const workspace = this.state.selectedWorkspace;
const machineId = selectedMachineId(this.state);
return {
insertFileReference: async (path: string) => {
if (!workspace) throw new Error("No workspace selected");
try {
await workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId);
} catch {
throw new Error(`File not found in workspace: ${path}`);
}
const reference = `@${path}`;
const editor = this.promptEditor?.view;
if (editor) {
const sel = editor.state.selection.main;
editor.dispatch({
changes: { from: sel.from, to: sel.to, insert: reference },
selection: { anchor: sel.from + reference.length },
});
}
return reference;
},
getAttachedFiles: () => {
const text = this.promptEditor?.view?.state.doc.toString() ?? "";
const matches: string[] = [];
const atFilePattern = /@([\w./\-\u00C0-\u024F]+(?:\.[\w]+))/g;
let m: RegExpExecArray | null;
while ((m = atFilePattern.exec(text)) !== null) {
if (m[1] !== undefined) matches.push(m[1]);
}
return matches;
},
removeFileReference: (path: string) => {
const editor = this.promptEditor?.view;
if (!editor) return;
const text = editor.state.doc.toString();
const reference = `@${path}`;
const index = text.indexOf(reference);
if (index === -1) return;
editor.dispatch({
changes: { from: index, to: index + reference.length, insert: "" },
selection: { anchor: index },
});
},
};
}
@@ -1479,7 +1413,6 @@ export class PiWebApp extends LitElement {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
prompt: this.createPromptEditor(),
attachments: this.createPluginAttachments(),
piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
-56
View File
@@ -27,9 +27,6 @@ interface PendingAttachment {
size: number;
}
type PluginPasteHandler = (event: ClipboardEvent) => boolean;
type PluginKeydownHandler = (event: KeyboardEvent) => boolean;
@customElement("prompt-editor")
export class PromptEditor extends LitElement {
@property({ type: Boolean }) disabled = false;
@@ -59,10 +56,6 @@ export class PromptEditor extends LitElement {
private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment();
private readonly readOnlyCompartment = new Compartment();
private readonly pluginHandlersCompartment = new Compartment();
private nextHandlerId = 0;
private readonly pasteHandlers = new Map<number, PluginPasteHandler>();
private readonly keydownHandlers = new Map<number, PluginKeydownHandler>();
protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId") && !changed.has("machineId")) return;
@@ -86,8 +79,6 @@ export class PromptEditor extends LitElement {
}
override disconnectedCallback(): void {
this.pasteHandlers.clear();
this.keydownHandlers.clear();
this.editor?.destroy();
this.editor = undefined;
super.disconnectedCallback();
@@ -128,52 +119,6 @@ export class PromptEditor extends LitElement {
return this.editor;
}
/** Register a plugin event handler. Returns a numeric ID for later removal. */
addPluginHandler(...args: ["paste", PluginPasteHandler] | ["keydown", PluginKeydownHandler]): number {
const [type, handler] = args;
const id = this.nextHandlerId++;
if (type === "paste") {
this.pasteHandlers.set(id, handler);
} else {
this.keydownHandlers.set(id, handler);
}
this.reconfigurePluginHandlers();
return id;
}
/** Remove a previously registered plugin event handler by ID. */
removePluginHandler(id: number): void {
this.pasteHandlers.delete(id);
this.keydownHandlers.delete(id);
this.reconfigurePluginHandlers();
}
private reconfigurePluginHandlers(): void {
const extension = this.buildPluginHandlersExtension();
this.editor?.dispatch({
effects: this.pluginHandlersCompartment.reconfigure(extension),
});
}
private buildPluginHandlersExtension() {
const pasteHandlers = [...this.pasteHandlers.values()];
const keydownHandlers = [...this.keydownHandlers.values()];
return EditorView.domEventHandlers({
paste(event) {
for (const handler of pasteHandlers) {
if (handler(event)) return true;
}
return false;
},
keydown(event) {
for (const handler of keydownHandlers) {
if (handler(event)) return true;
}
return false;
},
});
}
private renderCompactStatus() {
const status = this.status;
if (status === undefined) return null;
@@ -283,7 +228,6 @@ export class PromptEditor extends LitElement {
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
this.pluginHandlersCompartment.of(this.buildPluginHandlersExtension()),
EditorView.updateListener.of((update) => {
if (update.docChanged) this.updateDraft(update.state.doc.toString());
}),
-8
View File
@@ -18,14 +18,6 @@ function createContext(statePatch: Partial<AppState> = {}) {
insertText: vi.fn(),
getText: vi.fn(() => ""),
getSelection: vi.fn(() => null),
onPaste: vi.fn(() => vi.fn()),
onKeyDown: vi.fn(() => vi.fn()),
focus: vi.fn(() => { calls.push("prompt.focus"); }),
},
attachments: {
insertFileReference: vi.fn(),
getAttachedFiles: vi.fn(() => []),
removeFileReference: vi.fn(),
},
piWebUnstable: {
terminalCommandRuns: {
-10
View File
@@ -90,21 +90,11 @@ export interface PluginPromptEditor {
insertText(text: string): void;
getText(): string;
getSelection(): { start: number; end: number; text: string } | null;
onPaste(handler: (event: ClipboardEvent) => boolean): () => void;
onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void;
focus(): void;
}
export interface PluginAttachments {
insertFileReference(path: string): Promise<string>;
getAttachedFiles(): string[];
removeFileReference(path: string): void;
}
export interface PluginRuntimeContext {
state: AppState;
prompt: PluginPromptEditor;
attachments: PluginAttachments;
piWebUnstable?: PiWebUnstableRuntimeContext;
openActionPalette: () => void;
focusPrompt: () => void;