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:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@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
@@ -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. |
|
| `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. |
|
| `getText()` | Returns the full prompt text. |
|
||||||
| `getSelection()` | Returns `{ start, end, text }` if text is selected, or `null`. |
|
| `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:
|
Usage:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Insert text at cursor
|
// Insert text at the cursor (e.g. a file mention)
|
||||||
context.prompt.insertText("@file.txt");
|
context.prompt.insertText("@file.txt");
|
||||||
|
|
||||||
// Intercept paste events
|
// Read the current prompt and selection
|
||||||
const unsub = context.prompt.onPaste((event) => {
|
const text = context.prompt.getText();
|
||||||
const items = event.clipboardData?.items;
|
const selection = context.prompt.getSelection(); // { start, end, text } | null
|
||||||
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();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor.
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
#### Keyboard shortcuts
|
#### 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.
|
9. Add workspace panels for larger workspace UI.
|
||||||
10. Add workspace labels for compact inline metadata.
|
10. Add workspace labels for compact inline metadata.
|
||||||
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
|
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.
|
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.
|
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.
|
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
|||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import { selectedMachineId } from "../controllers/types";
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
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 { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
import { corePlugin } from "../plugins/core";
|
import { corePlugin } from "../plugins/core";
|
||||||
import { themePackPlugin } from "../plugins/themes";
|
import { themePackPlugin } from "../plugins/themes";
|
||||||
@@ -1406,72 +1406,6 @@ export class PiWebApp extends LitElement {
|
|||||||
if (sel.empty) return null;
|
if (sel.empty) return null;
|
||||||
return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) };
|
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({
|
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
|
||||||
state: this.state,
|
state: this.state,
|
||||||
prompt: this.createPromptEditor(),
|
prompt: this.createPromptEditor(),
|
||||||
attachments: this.createPluginAttachments(),
|
|
||||||
piWebUnstable: {
|
piWebUnstable: {
|
||||||
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
|
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
|
||||||
openSettings: (section) => { this.openSettings(section); },
|
openSettings: (section) => { this.openSettings(section); },
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ interface PendingAttachment {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginPasteHandler = (event: ClipboardEvent) => boolean;
|
|
||||||
type PluginKeydownHandler = (event: KeyboardEvent) => boolean;
|
|
||||||
|
|
||||||
@customElement("prompt-editor")
|
@customElement("prompt-editor")
|
||||||
export class PromptEditor extends LitElement {
|
export class PromptEditor extends LitElement {
|
||||||
@property({ type: Boolean }) disabled = false;
|
@property({ type: Boolean }) disabled = false;
|
||||||
@@ -59,10 +56,6 @@ export class PromptEditor extends LitElement {
|
|||||||
private editor: EditorView | undefined;
|
private editor: EditorView | undefined;
|
||||||
private readonly editableCompartment = new Compartment();
|
private readonly editableCompartment = new Compartment();
|
||||||
private readonly readOnlyCompartment = 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>) {
|
protected override willUpdate(changed: PropertyValues<this>) {
|
||||||
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
||||||
@@ -86,8 +79,6 @@ export class PromptEditor extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
this.pasteHandlers.clear();
|
|
||||||
this.keydownHandlers.clear();
|
|
||||||
this.editor?.destroy();
|
this.editor?.destroy();
|
||||||
this.editor = undefined;
|
this.editor = undefined;
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
@@ -128,52 +119,6 @@ export class PromptEditor extends LitElement {
|
|||||||
return this.editor;
|
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() {
|
private renderCompactStatus() {
|
||||||
const status = this.status;
|
const status = this.status;
|
||||||
if (status === undefined) return null;
|
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"),
|
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
|
||||||
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||||
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
||||||
this.pluginHandlersCompartment.of(this.buildPluginHandlersExtension()),
|
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) this.updateDraft(update.state.doc.toString());
|
if (update.docChanged) this.updateDraft(update.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
|||||||
insertText: vi.fn(),
|
insertText: vi.fn(),
|
||||||
getText: vi.fn(() => ""),
|
getText: vi.fn(() => ""),
|
||||||
getSelection: vi.fn(() => null),
|
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: {
|
piWebUnstable: {
|
||||||
terminalCommandRuns: {
|
terminalCommandRuns: {
|
||||||
|
|||||||
@@ -90,21 +90,11 @@ export interface PluginPromptEditor {
|
|||||||
insertText(text: string): void;
|
insertText(text: string): void;
|
||||||
getText(): string;
|
getText(): string;
|
||||||
getSelection(): { start: number; end: number; text: string } | null;
|
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 {
|
export interface PluginRuntimeContext {
|
||||||
state: AppState;
|
state: AppState;
|
||||||
prompt: PluginPromptEditor;
|
prompt: PluginPromptEditor;
|
||||||
attachments: PluginAttachments;
|
|
||||||
piWebUnstable?: PiWebUnstableRuntimeContext;
|
piWebUnstable?: PiWebUnstableRuntimeContext;
|
||||||
openActionPalette: () => void;
|
openActionPalette: () => void;
|
||||||
focusPrompt: () => void;
|
focusPrompt: () => void;
|
||||||
|
|||||||
@@ -81,37 +81,11 @@ export interface PluginPromptEditor {
|
|||||||
getText(): string;
|
getText(): string;
|
||||||
/** Get the current selection range, or null if no selection or editor not mounted. */
|
/** Get the current selection range, or null if no selection or editor not mounted. */
|
||||||
getSelection(): { start: number; end: number; text: string } | null;
|
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 {
|
export interface PluginRuntimeContext {
|
||||||
state: PluginRuntimeState;
|
state: PluginRuntimeState;
|
||||||
prompt: PluginPromptEditor;
|
prompt: PluginPromptEditor;
|
||||||
attachments: PluginAttachments;
|
|
||||||
openActionPalette: () => void;
|
openActionPalette: () => void;
|
||||||
focusPrompt: () => void;
|
focusPrompt: () => void;
|
||||||
addProject: () => void | Promise<void>;
|
addProject: () => void | Promise<void>;
|
||||||
|
|||||||
@@ -240,6 +240,22 @@ describe("deleteWorkspaceFile", () => {
|
|||||||
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
||||||
expect(realContent).toBe("real content");
|
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", () => {
|
describe("moveWorkspaceFile", () => {
|
||||||
|
|||||||
@@ -86,12 +86,19 @@ export async function deleteWorkspaceFile(rootPath: string, path: string | undef
|
|||||||
// deletes the symlink itself, not the target it points to.
|
// deletes the symlink itself, not the target it points to.
|
||||||
// resolveInsideWorkspace would call realpath on the target, following
|
// resolveInsideWorkspace would call realpath on the target, following
|
||||||
// symlinks and resolving the symlink's destination instead.
|
// symlinks and resolving the symlink's destination instead.
|
||||||
const { target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
|
||||||
try {
|
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
|
// Allow deleting regular files and symlinks, but not directories
|
||||||
if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead");
|
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 };
|
return { path: relativePath, existed: true };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false };
|
if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false };
|
||||||
|
|||||||
Reference in New Issue
Block a user