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 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
Add file mutation, prompt editor, and attachment APIs to the plugin system, completing the stable workspace interaction surface.
Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer.
+6 -51
View File
@@ -474,64 +474,19 @@ The `prompt` helper on `PluginRuntimeContext` provides stable access to the chat
| `insertText(text)` | Insert text at cursor position. When text is selected, replaces the selection. Focuses the editor first if not focused. |
| `getText()` | Returns the full prompt text. |
| `getSelection()` | Returns `{ start, end, text }` if text is selected, or `null`. |
| `onPaste(handler)` | Register a paste handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. |
| `onKeyDown(handler)` | Register a keydown handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. |
| `focus()` | Focus the prompt editor. |
Usage:
```js
// Insert text at cursor
// Insert text at the cursor (e.g. a file mention)
context.prompt.insertText("@file.txt");
// Intercept paste events
const unsub = context.prompt.onPaste((event) => {
const items = event.clipboardData?.items;
if (items?.[0]?.type.startsWith("image/")) {
// Handle image paste
return true; // consume the event
}
return false;
});
// Later, when the plugin no longer needs the handler:
unsub();
// Read the current prompt and selection
const text = context.prompt.getText();
const selection = context.prompt.getSelection(); // { start, end, text } | null
```
Handlers registered via `onPaste` and `onKeyDown` are scoped to the prompt editor using CodeMirror's extension system. They run in registration order: if a handler returns `true` to consume the event, subsequent plugin handlers for the same event will not run (first-to-consume-wins). Register handlers early if your plugin needs to intercept events before others. Handlers are automatically cleaned up when the editor is destroyed. Call the returned unsubscribe function when your plugin no longer needs the handler. Do not use `document.addEventListener` for prompt interactions — raw DOM listeners are not scoped to the editor, can leak memory, and may break across PI WEB upgrades.
`focusPrompt()` on `PluginRuntimeContext` is kept for backward compatibility. `prompt.focus()` is the preferred path.
### Attachment API
The `attachments` helper on `PluginRuntimeContext` manages file references in the chat prompt:
| Method | Description |
| --- | --- |
| `insertFileReference(path)` | Validate a workspace file exists and insert `@path` at the cursor. Returns the reference string. Throws if no workspace is selected or the file does not exist. |
| `getAttachedFiles()` | Returns an array of file paths currently referenced in the prompt (without the `@` prefix). |
| `removeFileReference(path)` | Remove the first occurrence of `@path` from the prompt. |
Usage:
```js
// Save a file, then attach it
const result = await context.files.writeFile(".pi-paste/screenshot.png", imageBytes);
const ref = await context.attachments.insertFileReference(result.path);
// ref is "@.pi-paste/screenshot.png"
// Check what's attached
const files = context.attachments.getAttachedFiles();
// files is [".pi-paste/screenshot.png"]
// Remove it
context.attachments.removeFileReference(".pi-paste/screenshot.png");
```
`insertFileReference` validates the file exists using `files.readFile()` before inserting the `@path` reference. Use `files.writeFile()` to create the file first, then `attachments.insertFileReference()` to attach it.
`getAttachedFiles()` uses a pattern that matches `@path/to/file.ext` — it requires a file extension (`.something`) to avoid matching email addresses like `user@example.com`. Paths are returned without the `@` prefix.
`removeFileReference(path)` removes the first occurrence of `@path` in the prompt text. If the path is not found, it does nothing.
Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor.
#### Keyboard shortcuts
@@ -966,7 +921,7 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
9. Add workspace panels for larger workspace UI.
10. Add workspace labels for compact inline metadata.
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, `prompt`, and `attachments`.
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, and `prompt`.
13. Do not fetch PI WEB `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers.
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
+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 };