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;
-26
View File
@@ -81,37 +81,11 @@ export interface PluginPromptEditor {
getText(): string;
/** Get the current selection range, or null if no selection or editor not mounted. */
getSelection(): { start: number; end: number; text: string } | null;
/** Register a paste event handler scoped to the prompt editor.
* Handlers run in registration order; first handler returning true consumes the event.
* Returns an unsubscribe function. No-op if the editor is not mounted. */
onPaste(handler: (event: ClipboardEvent) => boolean): () => void;
/** Register a keydown handler scoped to the prompt editor.
* Handlers run in registration order; first handler returning true consumes the event.
* Returns unsubscribe. No-op if the editor is not mounted. */
onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void;
/** Focus the prompt editor. No-op if not mounted. */
focus(): void;
}
export interface PluginAttachments {
/** Insert a file reference at the current cursor position in the chat prompt.
* Validates that the file exists in the workspace before insertion.
* Does not auto-focus the editor (unlike prompt.insertText). Use prompt.focus() first if needed.
* @throws Error if no workspace is selected or the file doesn't exist
* Returns the canonical @file reference string (e.g., "@path/to/file.png"). */
insertFileReference(path: string): Promise<string>;
/** List currently attached file paths in the prompt. Returns paths without the @ prefix.
* Best-effort heuristic: matches @path/to/file.ext patterns. May match email-like patterns;
* use insertFileReference() for guaranteed-accurate insertion. */
getAttachedFiles(): string[];
/** Remove a file reference from the prompt by path. Removes the first occurrence of @path. */
removeFileReference(path: string): void;
}
export interface PluginRuntimeContext {
state: PluginRuntimeState;
prompt: PluginPromptEditor;
attachments: PluginAttachments;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
@@ -240,6 +240,22 @@ describe("deleteWorkspaceFile", () => {
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
expect(realContent).toBe("real content");
});
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// A real file living outside the workspace that must not be deletable.
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "victim.txt"), "important");
// A symlinked parent directory inside the workspace pointing outside.
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
// The outside file must survive.
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
expect(realContent).toBe("important");
});
});
describe("moveWorkspaceFile", () => {
+10 -3
View File
@@ -86,12 +86,19 @@ export async function deleteWorkspaceFile(rootPath: string, path: string | undef
// deletes the symlink itself, not the target it points to.
// resolveInsideWorkspace would call realpath on the target, following
// symlinks and resolving the symlink's destination instead.
const { target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
try {
const s = await lstat(target);
// Resolve symlinks in the parent path to prevent escape via a symlinked
// parent directory. The final path component is intentionally NOT resolved
// so that lstat/unlink act on the entry itself (deleting a symlink rather
// than the file it points to).
const realParent = await realpath(dirname(target));
const realTarget = join(realParent, basename(target));
ensureInside(root, realTarget);
const s = await lstat(realTarget);
// Allow deleting regular files and symlinks, but not directories
if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead");
await unlink(target);
await unlink(realTarget);
return { path: relativePath, existed: true };
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false };