Archived
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:
@@ -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); },
|
||||
|
||||
@@ -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());
|
||||
}),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user