From 27a3b2b5edad8c41e5a00ea82b9902a9df895239 Mon Sep 17 00:00:00 2001 From: marcus Date: Sun, 14 Jun 2026 11:21:50 +0200 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20Plugin=20API=20Completeness=20?= =?UTF-8?q?=E2=80=94=20file=20mutations,=20prompt=20editor,=20and=20attach?= =?UTF-8?q?ment=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspaceFiles: writeFile, deleteFile, moveFile with path safety - writeFile: text/binary, auto-create dirs, overwrite option - deleteFile: idempotent, uses lstat (removes symlinks not targets) - moveFile: unix mv semantics, overwrite defaults to false - All mutations auto-refreshFiles() in File Explorer - Symlink escape prevention via realpath(dirname) check - PluginPromptEditor: insertText, getText, getSelection, onPaste, onKeyDown, focus - Uses CM6 EditorView.domEventHandlers() via Compartment (not raw DOM) - Handlers registered before mount are preserved and applied on mount - First-to-consume-wins ordering for multi-plugin scenarios - insertText replaces selection (not inserts after) - PluginAttachments: insertFileReference, getAttachedFiles, removeFileReference - insertFileReference validates file exists before inserting @path - Does not auto-focus editor (unlike prompt.insertText) - @file regex requires file extension to avoid matching emails - Server endpoints: PUT /file, DELETE /file, POST /file/move - All work for local and federated machines - Tests: 31 unit tests, 9 integration tests, 5 client tests - Docs: 3 new sections in plugins.md --- .changeset/plugin-api-completeness.md | 5 + .gitignore | 4 + docs/plugins.md | 175 +++++++++++- package.json | 1 + scripts/postinstall.mjs | 35 +++ src/client/src/api.ts | 2 +- src/client/src/api/clients.test.ts | 63 ++++ src/client/src/api/clients.ts | 31 +- src/client/src/api/http.ts | 2 +- src/client/src/api/parsers.ts | 30 +- src/client/src/components/PiWebApp.ts | 103 ++++++- src/client/src/components/PromptEditor.ts | 61 ++++ src/client/src/plugins/registry.test.ts | 46 ++- src/client/src/plugins/types.ts | 22 +- src/plugin-api.ts | 55 +++- src/server/app.test.ts | 268 +++++++++++++++++- src/server/piWebPluginService.test.ts | 14 +- src/server/workspaceExplorerRoutes.ts | 43 ++- .../workspaces/fileContentService.test.ts | 248 +++++++++++++++- src/server/workspaces/fileContentService.ts | 105 ++++++- src/server/workspaces/pathSafety.ts | 4 +- src/shared/apiTypes.ts | 29 ++ 22 files changed, 1314 insertions(+), 32 deletions(-) create mode 100644 .changeset/plugin-api-completeness.md create mode 100644 scripts/postinstall.mjs diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md new file mode 100644 index 0000000..0bff30c --- /dev/null +++ b/.changeset/plugin-api-completeness.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add file mutation, prompt editor, and attachment APIs to the plugin system, completing the stable workspace interaction surface. diff --git a/.gitignore b/.gitignore index 052ea5e..c74fccc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ dist/ # Local runtime attachment uploads (created by the chat composer "save to folder" mode). .pi-web/ + +# Local paste upload directory and temporary working docs. +.pi-paste/ +docs/tmp/ diff --git a/docs/plugins.md b/docs/plugins.md index 0743670..6706641 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -439,6 +439,7 @@ interface PluginRuntimeContext { selectedSession?: unknown; piWebStatus?: PiWebStatusResponse; }; + prompt: PluginPromptEditor; openActionPalette: () => void; focusPrompt: () => void; addProject: () => void | Promise; @@ -464,6 +465,74 @@ Notes: - `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal. - Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear. +### Prompt editor API + +The `prompt` helper on `PluginRuntimeContext` provides stable access to the chat prompt editor: + +| Method | Description | +| --- | --- | +| `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 +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(); +``` + +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. + #### Keyboard shortcuts - App-level keyboard shortcuts must be attached to actions. PI WEB does not support standalone plugin keyboard commands; contribute an action first, then add a `shortcut` if it needs a keybinding. @@ -521,6 +590,9 @@ interface WorkspacePanelContext { state?: PluginRuntimeState; files: { readFile(path: string): Promise; + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + deleteFile(path: string): Promise; + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; }; terminal: { open(options?: { terminalId?: string }): void; @@ -539,7 +611,7 @@ interface WorkspacePanelContext { `icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide. -`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`. +`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`. For compatibility, PI WEB still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`. @@ -607,6 +679,9 @@ interface WorkspaceLabelContext { state?: PluginRuntimeState; files: { readFile(path: string): Promise; + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + deleteFile(path: string): Promise; + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; }; host: { requestRender(): void; @@ -614,7 +689,7 @@ interface WorkspaceLabelContext { } ``` -`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks. +`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks. Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes. @@ -753,6 +828,96 @@ workspaceLabels: [ The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin. +## Writing, deleting, and moving workspace files + +Workspace panels and workspace labels can write, delete, and move files through the documented `files` helper. Like `readFile`, PI WEB binds these helpers to the callback's machine and workspace, so they work the same for local and federated machines. + +### Writing files + +```js +workspacePanels: [ + { + id: "workspace.generate", + title: "Generate", + render: ({ files }) => html` + + `, + }, +] +``` + +### Binary writes + +Pass a `Uint8Array` for binary content such as images: + +```js +const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); +await files.writeFile("screenshots/thumb.png", png); +``` + +### Options + +`files.writeFile` accepts an optional third argument: + +- `createDirs` (default `true`): create intermediate directories, like `mkdir -p`. +- `overwrite` (default `true`): overwrite existing files. Set to `false` to throw if the file already exists. + +```js +// Create only — throw if the file already exists +await files.writeFile("config/new-config.json", jsonContent, { overwrite: false }); +``` + +### Deleting files + +`files.deleteFile` removes a workspace file. It is idempotent: deleting a file that does not exist returns `{ existed: false }` instead of throwing. + +```js +const result = await files.deleteFile("temp/cache.json"); +console.log(result.existed ? "File deleted" : "File did not exist"); +``` + +### Moving files + +`files.moveFile` renames or moves a file within the workspace, like `mv`. The default is safe: it will not overwrite an existing target file. + +```js +// Rename a file +await files.moveFile("old-name.txt", "new-name.txt"); + +// Move into a subdirectory (creates intermediate dirs by default) +await files.moveFile("file.txt", "archive/file.txt"); + +// Overwrite an existing target +await files.moveFile("incoming.txt", "current.txt", { overwrite: true }); + +// Move without creating intermediate directories +await files.moveFile("file.txt", "deep/nested/file.txt", { createDirs: false }); // throws if dirs don't exist +``` + +`files.moveFile` accepts an optional third argument: + +- `createDirs` (default `true`): create intermediate directories for the target path. +- `overwrite` (default `false`): overwrite the target file if it exists. The default is safer than `writeFile` because moving is a more destructive operation. + +### Error handling + +All file mutations share the same safety layer: + +- `overwrite: false` on `writeFile` or existing target on `moveFile` (default) throws if the file already exists. +- Path traversal (e.g., `../../etc/passwd`) is blocked by the workspace safety layer. +- Writing to or moving to a path that is a directory returns an error. +- Deleting a directory returns an error. +- Intermediate directory creation with `createDirs: false` fails if the parent directory does not exist. + +After any mutation (`writeFile`, `deleteFile`, or `moveFile`), the File Explorer updates automatically. No explicit `refreshFiles()` call is needed from plugin code. For label and badge updates, call `context.host.requestRender()` if the UI should reflect the change. + +### Security + +Plugins are trusted browser code. File writes go through the same path safety validation as reads — paths are resolved and checked to stay inside the workspace root. + ## Running workspace terminal commands Workspace panels can start terminal commands through the documented `terminal` helper. Commands run in the current workspace on the panel's machine. @@ -801,7 +966,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`, and `state.piWebStatus`. +12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, `prompt`, and `attachments`. 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. @@ -811,13 +976,13 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis Check discovery: ```bash -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +curl http://localhost:8504/pi-web-plugins/manifest.json ``` Check a plugin module: ```bash -curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js +curl http://localhost:8504/pi-web-plugins/my-plugin/pi-web-plugin.js ``` Common issues: diff --git a/package.json b/package.json index 11e045c..2783bc6 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "prepublishOnly": "npm run verify", "publish:npm": "npm publish --access public", "prepare": "node scripts/install-git-hooks.mjs", + "postinstall": "node scripts/postinstall.mjs", "changeset": "changeset", "release:version": "changeset version", "changelog:status": "changeset status" diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 0000000..66b023e --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,35 @@ +import { chmodSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * node-pty 1.1.0 ships macOS prebuilds with `spawn-helper` files at 644 + * instead of 755, causing `posix_spawnp failed` at runtime. This script + * fixes permissions after install on Darwin platforms. + */ +function fixNodePtyPermissions() { + if (process.platform === "win32") return; + const prebuildsDir = join("node_modules", "node-pty", "prebuilds"); + let entries; + try { + entries = readdirSync(prebuildsDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const helper = join(prebuildsDir, entry.name, "spawn-helper"); + let stats; + try { + stats = statSync(helper); + } catch { + continue; + } + if (!stats.isFile()) continue; + // 0o100 = regular file, 0o111 = owner/group/other execute + if ((stats.mode & 0o111) === 0) { + chmodSync(helper, 0o755); + } + } +} + +fixNodePtyPermissions(); diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 63d4243..c6cbeb7 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 90b865a..659ba47 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -147,6 +147,69 @@ describe("machine-scoped terminal command-run API", () => { }); }); +describe("workspace file write API", () => { + it("sends text content with Content-Type text/plain", async () => { + const fetchMock = stubJsonFetch({ path: "hello.txt", size: 11, modifiedAt: "2026-06-10T00:00:00.000Z", created: true }); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "hello.txt", "hello world"); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchCall(fetchMock, 0); + expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); + expect(init?.method).toBe("PUT"); + expect(new Headers(init?.headers).get("content-type")).toBe("text/plain"); + }); + + it("sends binary content with Content-Type application/octet-stream", async () => { + const fetchMock = stubJsonFetch({ path: "image.png", size: 4, modifiedAt: "2026-06-10T00:00:00.000Z", created: true }); + const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "image.png", binary); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchCall(fetchMock, 0); + expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); + expect(init?.method).toBe("PUT"); + expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); + }); + + it("sends createDirs and overwrite query parameters", async () => { + const fetchMock = stubJsonFetch({ path: "config/new.json", size: 10, modifiedAt: "2026-06-10T00:00:00.000Z", created: true }); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "config/new.json", "{\"a\":1}", { createDirs: false, overwrite: false }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url] = fetchCall(fetchMock, 0); + expect(url).toContain("createDirs=false"); + expect(url).toContain("overwrite=false"); + }); + + it("parses WriteWorkspaceFileResponse correctly", async () => { + const fetchMock = stubJsonFetch({ path: "output/result.txt", size: 42, modifiedAt: "2026-06-10T12:00:00.000Z", created: true }); + + const result = await workspacesApi.writeWorkspaceFile("p 1", "w/1", "output/result.txt", "content"); + + expect(fetchMock).toHaveBeenCalledOnce(); + + expect(result).toEqual({ + path: "output/result.txt", + size: 42, + modifiedAt: "2026-06-10T12:00:00.000Z", + created: true, + }); + }); + + it("routes through machine prefix for remote machines", async () => { + const fetchMock = stubJsonFetch({ path: "file.txt", size: 5, modifiedAt: "2026-06-10T00:00:00.000Z", created: false }); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "file.txt", "data", undefined, "remote a"); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url] = fetchCall(fetchMock, 0); + expect(url).toContain("/api/machines/remote%20a/"); + }); +}); + type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise; type FetchMock = ReturnType>; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 628cdd5..bd36da7 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; +import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import { request } from "./http"; import { arrayOf, @@ -9,6 +9,7 @@ import { parseClosed, parseCommandResult, parseDeleted, + parseDeleteWorkspaceFileResponse, parseDetached, parseFileContentResponse, parseFileSuggestion, @@ -21,6 +22,7 @@ import { parseMachinesResponse, parseMessagePage, parseModelSelectionResponse, + parseMoveWorkspaceFileResponse, parseOAuthFlowState, parsePiWebConfigResponse, parsePiWebPluginsResponse, @@ -37,6 +39,7 @@ import { parseTerminalCommandRun, parseTerminalInfo, parseThinkingLevelsResponse, + parseWriteWorkspaceFileResponse, parseWorkspace, parseWorkspaceActivityResponse, } from "./parsers"; @@ -118,6 +121,32 @@ export const workspacesApi = { deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }), workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse), workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse), + writeWorkspaceFile: (projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId = "local") => { + const params = new URLSearchParams({ path }); + if (options?.createDirs === false) params.set("createDirs", "false"); + if (options?.overwrite === false) params.set("overwrite", "false"); + const isBinary = content instanceof Uint8Array; + const body: BodyInit = isBinary ? new Uint8Array(content) : new TextEncoder().encode(content); + return request( + `${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, + parseWriteWorkspaceFileResponse, + { method: "PUT", body, headers: { "Content-Type": isBinary ? "application/octet-stream" : "text/plain" } }, + ); + }, + deleteWorkspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local"): Promise => { + const params = new URLSearchParams({ path }); + return request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, parseDeleteWorkspaceFileResponse, { method: "DELETE" }); + }, + moveWorkspaceFile: (projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId = "local") => { + const params = new URLSearchParams({ fromPath, toPath }); + if (options?.createDirs === false) params.set("createDirs", "false"); + if (options?.overwrite === true) params.set("overwrite", "true"); + return request( + `${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/move?${params.toString()}`, + parseMoveWorkspaceFileResponse, + { method: "POST" }, + ); + }, }; export const sessionsApi = { diff --git a/src/client/src/api/http.ts b/src/client/src/api/http.ts index f1c0da9..dd553ff 100644 --- a/src/client/src/api/http.ts +++ b/src/client/src/api/http.ts @@ -1,6 +1,6 @@ export async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { const headers = new Headers(init?.headers); - if (init?.body !== undefined) headers.set("content-type", "application/json"); + if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); const response = await fetch(url, { ...init, headers }); if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 23250cd..37b4a00 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import { isPiWebCapability } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { @@ -336,6 +336,34 @@ export function parseFileContentResponse(value: unknown): FileContentResponse { return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") }; } +export function parseWriteWorkspaceFileResponse(value: unknown): WriteWorkspaceFileResponse { + const record = requireRecord(value); + return { + path: requireString(record, "path"), + size: requireNumber(record, "size"), + modifiedAt: requireString(record, "modifiedAt"), + created: requireBoolean(record, "created"), + }; +} + +export function parseDeleteWorkspaceFileResponse(value: unknown): DeleteWorkspaceFileResponse { + const record = requireRecord(value); + return { + path: requireString(record, "path"), + existed: requireBoolean(record, "existed"), + }; +} + +export function parseMoveWorkspaceFileResponse(value: unknown): MoveWorkspaceFileResponse { + const record = requireRecord(value); + return { + fromPath: requireString(record, "fromPath"), + toPath: requireString(record, "toPath"), + size: requireNumber(record, "size"), + modifiedAt: requireString(record, "modifiedAt"), + }; +} + function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined { if (value === undefined) return undefined; if (value !== "image") throw new Error("Invalid file media type"); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 43dfa04..95493d3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; +import type { PiWebPluginRegistration, PluginMachine, PluginAttachments, 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"; @@ -1198,6 +1198,21 @@ export class PiWebApp extends LitElement { private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles { return { readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId), + writeFile: async (path, content, options) => { + const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId); + void this.files.refreshFiles(); + return result; + }, + deleteFile: async (path) => { + const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId); + void this.files.refreshFiles(); + return result; + }, + moveFile: async (fromPath, toPath, options) => { + const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId); + void this.files.refreshFiles(); + return result; + }, }; } @@ -1369,9 +1384,95 @@ export class PiWebApp extends LitElement { } } + private createPromptEditor(): PluginPromptEditor { + return { + insertText: (text: string) => { + const editor = this.promptEditor?.view; + if (!editor) return; + if (!editor.hasFocus) editor.focus(); + const sel = editor.state.selection.main; + editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: text } }); + }, + getText: () => { + return this.promptEditor?.view?.state.doc.toString() ?? ""; + }, + getSelection: () => { + const editor = this.promptEditor?.view; + if (!editor) return null; + const sel = editor.state.selection.main; + 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 } }); + } + 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: "" }, + }); + }, + }; + } + private createPluginRuntimeContext(): PluginRuntimeContext { 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); }, diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 3d8eb40..ff0d7ee 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -27,6 +27,9 @@ 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; @@ -56,6 +59,10 @@ 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(); + private readonly keydownHandlers = new Map(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -79,6 +86,8 @@ export class PromptEditor extends LitElement { } override disconnectedCallback(): void { + this.pasteHandlers.clear(); + this.keydownHandlers.clear(); this.editor?.destroy(); this.editor = undefined; super.disconnectedCallback(); @@ -114,6 +123,57 @@ export class PromptEditor extends LitElement { this.editor?.focus(); } + /** Get the underlying CM6 EditorView, or undefined if not yet mounted. */ + get view(): EditorView | undefined { + 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; @@ -223,6 +283,7 @@ 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()); }), diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 7be8258..0c0d538 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -1,6 +1,6 @@ import { html } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api"; +import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api"; import { initialAppState, type AppState } from "../appState"; import { markCachedNewSessionInfo } from "../cachedNewSessions"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; @@ -14,6 +14,19 @@ function createContext(statePatch: Partial = {}) { const calls: string[] = []; const context: PluginRuntimeContext = { state: { ...initialAppState(), ...statePatch }, + prompt: { + 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: { runCommand: vi.fn(), @@ -335,7 +348,7 @@ describe("PluginRegistry", () => { context.host.requestRender(); return [{ type: "text", text: context.machine.id }]; }); - const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } }); + const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } }); registry.register({ id: "example", @@ -545,7 +558,7 @@ function testWorkspace(patch: Partial = {}): Workspace { } function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial> = {}): WorkspaceLabelContext { - const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn(() => Promise.resolve(testFileContent())) }; + const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn(() => Promise.resolve(testFileContent())), writeFile: vi.fn(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn(() => Promise.resolve(testMoveFileResponse())) }; const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn() }; return { machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, @@ -562,7 +575,7 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext { machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, workspace, state: { ...initialAppState(), selectedMachine: testMachine(machineId) }, - files: { readFile: vi.fn() }, + files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() }, terminal: { open: vi.fn(), runCommand: vi.fn() }, host: { requestRender: vi.fn() }, fileTree: [], @@ -613,6 +626,31 @@ function testStatus(patch: Partial = {}): SessionStatus { }; } +function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse { + return { + path, + size: 0, + modifiedAt: "2026-05-20T00:00:00.000Z", + created: true, + }; +} + +function testDeleteFileResponse(path = "README.md"): DeleteWorkspaceFileResponse { + return { + path, + existed: true, + }; +} + +function testMoveFileResponse(fromPath = "old.txt", toPath = "new.txt"): MoveWorkspaceFileResponse { + return { + fromPath, + toPath, + size: 0, + modifiedAt: "2026-05-20T00:00:00.000Z", + }; +} + function testMachine(id: string) { return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" }; } diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 2b44063..d7cf32e 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -1,6 +1,6 @@ import type { TemplateResult } from "lit"; import type { AppAction } from "../actions"; -import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api"; +import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api"; import type { AppState } from "../appState"; import type { SettingsSection } from "../settingsRoute"; import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; @@ -50,6 +50,9 @@ export interface PluginMachine { export interface WorkspaceFiles { readFile(path: string): Promise; + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + deleteFile(path: string): Promise; + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; } export interface WorkspaceHost { @@ -83,8 +86,25 @@ export interface TerminalCommandRunsInternalRuntime { open(options?: { terminalId?: string | undefined }): void; } +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; + getAttachedFiles(): string[]; + removeFileReference(path: string): void; +} + export interface PluginRuntimeContext { state: AppState; + prompt: PluginPromptEditor; + attachments: PluginAttachments; piWebUnstable?: PiWebUnstableRuntimeContext; openActionPalette: () => void; focusPrompt: () => void; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 971e46c..3c195bb 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -1,5 +1,5 @@ import type { TemplateResult } from "lit"; -import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js"; +import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js"; export type { FileContentMediaType, @@ -20,6 +20,11 @@ export type { TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, + WriteWorkspaceFileOptions, + WriteWorkspaceFileResponse, + DeleteWorkspaceFileResponse, + MoveWorkspaceFileOptions, + MoveWorkspaceFileResponse, } from "./shared/apiTypes.js"; export type PluginId = string; @@ -67,8 +72,46 @@ export interface PluginRuntimeState { piWebStatus?: PiWebStatusResponse; } +export interface PluginPromptEditor { + /** Insert text at the current cursor position. Replaces any selection. + * If the editor is not focused, focuses it first. + * No-op if the editor is not mounted. */ + insertText(text: string): void; + /** Get the current prompt text content. Returns "" if the editor is not mounted. */ + 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; + /** 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; @@ -109,7 +152,17 @@ export interface Workspace { } export interface WorkspaceFiles { + /** Read a file from the workspace. Works for local and federated machines. */ readFile(path: string): Promise; + /** Write content to a workspace file. Creates intermediate directories by default. + * Works for local and federated machines. Auto-refreshes the file explorer after success. */ + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + /** Delete a file from the workspace. Idempotent — returns { existed: false } if file doesn't exist. + * Deletes the entry itself (for symlinks, removes the symlink not the target). */ + deleteFile(path: string): Promise; + /** Move or rename a file within the workspace. Unix mv semantics. + * Default overwrite: false (safer than writeFile). Auto-refreshes the file explorer after success. */ + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; } export type WorkspacePanelFiles = WorkspaceFiles; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 5eda1a5..c7c5923 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { Readable } from "node:stream"; @@ -478,6 +478,272 @@ describe("buildApp", () => { expect(tooLargeResponse.statusCode).toBe(400); expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" }); }); + + it("writes workspace files through the HTTP contract", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "WriteTest", path: projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + // Write a text file + const writeTextResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, + payload: "hello world", + headers: { "content-type": "text/plain" }, + }); + expect(writeTextResponse.statusCode).toBe(200); + expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true }); + const writeBody = writeTextResponse.json>(); + expect(typeof writeBody['size']).toBe("number"); + + // Read it back + const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` }); + const readBody = readResponse.json>(); + expect(readBody['content']).toBe("hello world"); + + // Write binary content + const writeBinaryResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`, + payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + headers: { "content-type": "application/octet-stream" }, + }); + expect(writeBinaryResponse.statusCode).toBe(200); + expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true }); + + // Create intermediate directories (default) + const writeDeepResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`, + payload: "deep content", + headers: { "content-type": "text/plain" }, + }); + expect(writeDeepResponse.statusCode).toBe(200); + + // Verify the nested file was written + const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` }); + const readDeepBody = readDeepResponse.json>(); + expect(readDeepBody['content']).toBe("deep content"); + + // Overwrite an existing file (default) + const overwriteResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, + payload: "updated", + headers: { "content-type": "text/plain" }, + }); + expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false }); + + // Reject overwrite=false when file exists + const noOverwriteResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(noOverwriteResponse.statusCode).toBe(400); + const noOverwriteBody = noOverwriteResponse.json>(); + expect(noOverwriteBody['error']).toContain("File already exists"); + + // Reject path traversal + const traversalResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, + payload: "evil", + headers: { "content-type": "text/plain" }, + }); + expect(traversalResponse.statusCode).toBe(400); + const traversalBody = traversalResponse.json>(); + expect(traversalBody['error']).toContain("Path traversal"); + + // Reject missing path + const noPathResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, + payload: "no path", + headers: { "content-type": "text/plain" }, + }); + expect(noPathResponse.statusCode).toBe(400); + const noPathBody = noPathResponse.json>(); + expect(noPathBody['error']).toContain("path query parameter is required"); + + // Fail when createDirs=false and parent directory does not exist + const noDirsResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(noDirsResponse.statusCode).toBe(400); + + // Reject writing to a directory path + await mkdir(join(projectDir, "subdir"), { recursive: true }); + const dirWriteResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(dirWriteResponse.statusCode).toBe(400); + }); + + it("deletes workspace files through the HTTP contract", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "DeleteTest", path: projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + // Write a file first so we can delete it + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, + payload: "delete me", + headers: { "content-type": "text/plain" }, + }); + + // Delete existing file + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, + }); + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true }); + + // Delete non-existent file (idempotent) + const deleteMissingResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`, + }); + expect(deleteMissingResponse.statusCode).toBe(200); + expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false }); + + // Reject path traversal + const traversalResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, + }); + expect(traversalResponse.statusCode).toBe(400); + const deleteTraversalBody = traversalResponse.json>(); + expect(deleteTraversalBody['error']).toContain("Path traversal"); + + // Reject missing path + const noPathResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, + }); + expect(noPathResponse.statusCode).toBe(400); + const deleteNoPathBody = noPathResponse.json>(); + expect(deleteNoPathBody['error']).toContain("path query parameter is required"); + }); + + it("moves workspace files through the HTTP contract", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "MoveTest", path: projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + // Write a file first so we can move it + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`, + payload: "move me", + headers: { "content-type": "text/plain" }, + }); + + // Move a file to a new path + const moveResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`, + }); + expect(moveResponse.statusCode).toBe(200); + expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); + const moveBody = moveResponse.json>(); + expect(typeof moveBody['size']).toBe("number"); + + // Verify source is gone + const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` }); + expect(readSourceResponse.statusCode).toBe(400); + + // Verify target exists + const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` }); + expect(readTargetResponse.statusCode).toBe(200); + const targetBody = readTargetResponse.json>(); + expect(targetBody['content']).toBe("move me"); + + // Write another file for overwrite test + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`, + payload: "source", + headers: { "content-type": "text/plain" }, + }); + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`, + payload: "target", + headers: { "content-type": "text/plain" }, + }); + + // Move with overwrite=true succeeds + const overwriteResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`, + }); + expect(overwriteResponse.statusCode).toBe(200); + + // Move with overwrite=false (default) fails when target exists + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`, + payload: "s", + headers: { "content-type": "text/plain" }, + }); + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`, + payload: "t", + headers: { "content-type": "text/plain" }, + }); + const noOverwriteResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`, + }); + expect(noOverwriteResponse.statusCode).toBe(400); + const moveNoOverwriteBody = noOverwriteResponse.json>(); + expect(moveNoOverwriteBody['error']).toContain("File already exists"); + + // Reject path traversal in fromPath + const traversalFromResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`, + }); + expect(traversalFromResponse.statusCode).toBe(400); + + // Reject missing params + const noParamsResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`, + }); + expect(noParamsResponse.statusCode).toBe(400); + const noParamsBody = noParamsResponse.json>(); + expect(noParamsBody['error']).toContain("fromPath query parameter is required"); + }); }); interface CapturedSessionDaemonRequest { diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 7b4ec66..1e11c73 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -22,7 +22,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); await expect(service.manifest()).resolves.toEqual({ plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], @@ -41,7 +41,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] }); await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] }); @@ -74,7 +74,7 @@ describe("PiWebPluginService", () => { files: { "dist/pi-web-plugin.js": "export default { apiVersion: 1, name: 'Source Dev', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false }); + const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins).toEqual(expect.arrayContaining([ @@ -92,7 +92,7 @@ describe("PiWebPluginService", () => { await mkdir(join(tempDir, "plugins"), { recursive: true }); await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); @@ -135,7 +135,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]); @@ -167,7 +167,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]); @@ -181,7 +181,7 @@ describe("PiWebPluginService", () => { }); await writeFile(join(tempDir, "plugins", "escape.js"), "nope"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); diff --git a/src/server/workspaceExplorerRoutes.ts b/src/server/workspaceExplorerRoutes.ts index 1c76e50..0e47449 100644 --- a/src/server/workspaceExplorerRoutes.ts +++ b/src/server/workspaceExplorerRoutes.ts @@ -1,12 +1,19 @@ import type { FastifyInstance } from "fastify"; import type { ProjectService } from "./projects/projectService.js"; import type { WorkspaceService } from "./workspaces/workspaceService.js"; +import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import { listWorkspaceTree } from "./workspaces/fileTreeService.js"; -import { readWorkspaceFile } from "./workspaces/fileContentService.js"; +import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js"; import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js"; export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void { + // Register content type parsers for workspace file writes. + // Fastify's default parser only handles application/json. + // Guard against re-registration since this function may be called multiple times. + try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_req, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ } + try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ } + try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/, { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ } app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); @@ -25,6 +32,40 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: } }); + app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + const options: WriteWorkspaceFileOptions = { + createDirs: request.query.createDirs !== "false", + overwrite: request.query.overwrite !== "false", + }; + return await writeWorkspaceFile(context.root, request.query.path, request.body, options); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.delete<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await deleteWorkspaceFile(context.root, request.query.path); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post<{ Params: { projectId: string; workspaceId: string }; Querystring: { fromPath?: string; toPath?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/move`, async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await moveWorkspaceFile(context.root, request.query.fromPath, request.query.toPath, { + createDirs: request.query.createDirs !== "false", + overwrite: request.query.overwrite === "true", + }); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index 22705da..232978b 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -1,9 +1,10 @@ -import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js"; -import { readWorkspaceFile } from "./fileContentService.js"; +import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js"; +import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js"; import { readWorkspaceImagePreview } from "./imagePreviewService.js"; const roots: string[] = []; @@ -96,3 +97,246 @@ describe("readWorkspaceFile", () => { expect(file.binary).toBe(false); }); }); + +describe("writeWorkspaceFile", () => { + it("writes text content to a new file with normalized paths", async () => { + const root = await tempWorkspace(); + + const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n")); + + expect(result).toMatchObject({ path: "src/hello.ts", created: true }); + expect(result.size).toBe(26); + expect(Date.parse(result.modifiedAt)).not.toBeNaN(); + + // Verify the file was actually written + const content = await readFile(join(root, "src", "hello.ts"), "utf8"); + expect(content).toBe("const greeting = 'hello';\n"); + }); + + it("writes binary content", async () => { + const root = await tempWorkspace(); + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + + const result = await writeWorkspaceFile(root, "image.png", binaryData); + + expect(result).toMatchObject({ path: "image.png", created: true, size: 6 }); + }); + + it("overwrites existing files by default", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "notes.txt"), "old content"); + + const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content")); + + expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 }); + const content = await readFile(join(root, "notes.txt"), "utf8"); + expect(content).toBe("new content"); + }); + + it("throws when overwrite is false and file exists", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "existing.txt"), "data"); + + await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists"); + }); + + it("creates intermediate directories by default", async () => { + const root = await tempWorkspace(); + + await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content")); + + const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8"); + expect(content).toBe("deep content"); + }); + + it("fails when createDirs is false and parent directory does not exist", async () => { + const root = await tempWorkspace(); + + await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow(); + }); + + it("rejects missing paths, traversal, and absolute paths", async () => { + const root = await tempWorkspace(); + + await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required"); + await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed"); + await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("rejects writing to a directory path", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file"); + }); + + it("prevents writing through symlinks that escape the workspace", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + // Create a symlink inside the workspace that points outside + const { symlink } = await import("node:fs/promises"); + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-")); + roots.push(outsideDir); + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + // Attempting to write through the symlink should be blocked + await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow(); + }); +}); + +describe("deleteWorkspaceFile", () => { + it("deletes an existing file and returns existed: true", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "notes.txt"), "hello"); + + const result = await deleteWorkspaceFile(root, "notes.txt"); + + expect(result).toMatchObject({ path: "notes.txt", existed: true }); + await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist"); + }); + + it("returns existed: false when deleting a non-existent file", async () => { + const root = await tempWorkspace(); + + const result = await deleteWorkspaceFile(root, "missing.txt"); + + expect(result).toMatchObject({ path: "missing.txt", existed: false }); + }); + + it("rejects deleting a directory", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory"); + }); + + it("rejects path traversal", async () => { + const root = await tempWorkspace(); + + await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("rejects missing path", async () => { + const root = await tempWorkspace(); + + await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); + await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required"); + }); + + it("deletes a symlink itself, not its target", async () => { + const root = await tempWorkspace(); + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-")); + roots.push(outsideDir); + await writeFile(join(outsideDir, "real.txt"), "real content"); + // Create a symlink inside the workspace pointing outside + await symlink(join(outsideDir, "real.txt"), join(root, "link.txt")); + + const result = await deleteWorkspaceFile(root, "link.txt"); + + expect(result).toMatchObject({ path: "link.txt", existed: true }); + // The symlink should be gone, but the target file should still exist + await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow(); + const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); + expect(realContent).toBe("real content"); + }); +}); + +describe("moveWorkspaceFile", () => { + it("moves a file to a new path", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "original.txt"), "content"); + + const result = await moveWorkspaceFile(root, "original.txt", "moved.txt"); + + expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); + expect(result.size).toBe(7); + expect(Date.parse(result.modifiedAt)).not.toBeNaN(); + // Source should no longer exist + await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist"); + // Target should exist + const target = await readWorkspaceFile(root, "moved.txt"); + expect(target.content).toBe("content"); + }); + + it("creates intermediate directories by default", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "file.txt"), "data"); + + await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt"); + + const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt"); + expect(target.content).toBe("data"); + }); + + it("fails when createDirs is false and parent directory does not exist", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "file.txt"), "data"); + + await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow(); + }); + + it("overwrites target when overwrite is true", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "source.txt"), "source content"); + await writeFile(join(root, "target.txt"), "target content"); + + const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true }); + + expect(result.toPath).toBe("target.txt"); + const target = await readWorkspaceFile(root, "target.txt"); + expect(target.content).toBe("source content"); + }); + + it("throws when target exists and overwrite is false (default)", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "source.txt"), "source"); + await writeFile(join(root, "target.txt"), "target"); + + await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists"); + // Source should still exist + const source = await readWorkspaceFile(root, "source.txt"); + expect(source.content).toBe("source"); + }); + + it("rejects source path traversal", async () => { + const root = await tempWorkspace(); + + await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed"); + }); + + it("rejects target path traversal", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "source.txt"), "data"); + + await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow(); + }); + + it("rejects moving a directory", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file"); + }); + + it("rejects missing fromPath or toPath", async () => { + const root = await tempWorkspace(); + + await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required"); + await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required"); + await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required"); + await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required"); + }); + + it("prevents moving through symlinks that escape the workspace", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + await writeFile(join(root, "subdir", "file.txt"), "data"); + // Create a symlink inside the workspace that points outside + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-")); + roots.push(outsideDir); + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow(); + }); +}); diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts index 1718141..58de7a2 100644 --- a/src/server/workspaces/fileContentService.ts +++ b/src/server/workspaces/fileContentService.ts @@ -1,7 +1,8 @@ -import { open, stat } from "node:fs/promises"; -import type { FileContentResponse } from "../../shared/apiTypes.js"; +import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js"; import { imageMimeTypeForPath } from "./imagePreviewService.js"; -import { resolveInsideWorkspace } from "./pathSafety.js"; +import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js"; const MAX_BYTES = 512 * 1024; @@ -39,6 +40,104 @@ async function readFilePrefix(target: string, bytesToRead: number): Promise { + if (path === undefined || path === "") throw new Error("path query parameter is required"); + + const createDirs = options.createDirs ?? true; + const overwrite = options.overwrite ?? true; + + let exists = false; + try { + const { target, relativePath } = await resolveInsideWorkspace(rootPath, path); + const s = await stat(target); + if (!s.isFile()) throw new Error("Path is not a file"); + if (!overwrite) throw new Error(`File already exists: ${relativePath}`); + exists = true; + } catch (error: unknown) { + if (error instanceof Error && error.message.startsWith("File already exists")) throw error; + if (isNodeErrorWithCode(error, "ENOENT")) { /* expected for creation — continue */ } + else if (error instanceof Error && error.message === "Path does not exist") { /* expected for creation — continue */ } + else throw error; // re-throw permission errors, "not a file", traversal errors, etc. + } + + // Use resolveParentInsideWorkspace for the actual write since the target may not exist yet + const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path); + + if (createDirs) await mkdir(dirname(target), { recursive: true }); + + // Resolve symlinks in the parent path to prevent escape via symlink + const realParent = await realpath(dirname(target)); + const realTarget = join(realParent, basename(target)); + ensureInside(root, realTarget); + await writeFile(realTarget, content); + + const s = await stat(realTarget); + return { + path: relativePath, + size: s.size, + modifiedAt: s.mtime.toISOString(), + created: !exists, + }; +} + +export async function deleteWorkspaceFile(rootPath: string, path: string | undefined): Promise { + if (path === undefined || path === "") throw new Error("path query parameter is required"); + // Use resolveParentInsideWorkspace + lstat so that deleting a symlink + // 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); + try { + const s = await lstat(target); + // 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); + return { path: relativePath, existed: true }; + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false }; + if (error instanceof Error && error.message === "Path does not exist") return { path: relativePath, existed: false }; + throw error; + } +} + +export async function moveWorkspaceFile(rootPath: string, fromPath: string | undefined, toPath: string | undefined, options: MoveWorkspaceFileOptions = {}): Promise { + if (fromPath === undefined || fromPath === "") throw new Error("fromPath query parameter is required"); + if (toPath === undefined || toPath === "") throw new Error("toPath query parameter is required"); + + const createDirs = options.createDirs ?? true; + const overwrite = options.overwrite ?? false; + + // Source: must exist and be a file (uses realpath via resolveInsideWorkspace) + const { target: source, relativePath: fromRelative } = await resolveInsideWorkspace(rootPath, fromPath); + const sourceStat = await stat(source); + if (!sourceStat.isFile()) throw new Error("Source path is not a file"); + + // Target: uses resolveParentInsideWorkspace + realpath(dirname) pattern (same as writeFile) + const { root, target: dest, relativePath: destRelative } = await resolveParentInsideWorkspace(rootPath, toPath); + + if (createDirs) await mkdir(dirname(dest), { recursive: true }); + + // Resolve symlinks in the parent path to prevent escape via symlink + const realParent = await realpath(dirname(dest)); + const realDest = join(realParent, basename(dest)); + ensureInside(root, realDest); + + if (!overwrite) { + try { + const destStat = await stat(realDest); + if (destStat.isFile()) throw new Error(`File already exists: ${destRelative}`); + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) { /* expected — target doesn't exist */ } + else if (error instanceof Error && error.message.startsWith("File already exists")) throw error; + else throw error; + } + } + + await rename(source, realDest); + const finalStat = await stat(realDest); + return { fromPath: fromRelative, toPath: destRelative, size: finalStat.size, modifiedAt: finalStat.mtime.toISOString() }; +} + function isProbablyBinary(buffer: Buffer): boolean { const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); return sample.includes(0); diff --git a/src/server/workspaces/pathSafety.ts b/src/server/workspaces/pathSafety.ts index 36d2cbd..02a8921 100644 --- a/src/server/workspaces/pathSafety.ts +++ b/src/server/workspaces/pathSafety.ts @@ -30,11 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string { return parts.join("/"); } -function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { +export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { return typeof error === "object" && error !== null && "code" in error && error.code === code; } -function ensureInside(root: string, target: string): void { +export function ensureInside(root: string, target: string): void { const rel = relative(root, target); if (rel === "") return; if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace"); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 6ce5db7..06bb685 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -297,6 +297,35 @@ export interface FileContentResponse { binary: boolean; } +export interface WriteWorkspaceFileOptions { + createDirs?: boolean; // default: true — mkdir -p equivalent + overwrite?: boolean; // default: true — throw if false and file exists +} + +export interface WriteWorkspaceFileResponse { + path: string; + size: number; + modifiedAt: string; + created: boolean; // true if file was created, false if overwritten +} + +export interface DeleteWorkspaceFileResponse { + path: string; + existed: boolean; // true if file existed and was deleted, false if file did not exist +} + +export interface MoveWorkspaceFileOptions { + createDirs?: boolean; // default: true — mkdir -p equivalent for target parent directory + overwrite?: boolean; // default: false — throw if target exists (safer default than writeFile) +} + +export interface MoveWorkspaceFileResponse { + fromPath: string; + toPath: string; + size: number; + modifiedAt: string; +} + export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted"; export interface GitStatusFile { From fd6c01067be31e3a4d0aae5457bccba65f6976cc Mon Sep 17 00:00:00 2001 From: marcus Date: Sun, 14 Jun 2026 13:08:02 +0200 Subject: [PATCH 02/27] fix(plugin-api): position cursor after insertions and removals in prompt/attachments API Ensure prompt.insertText, attachments.insertFileReference, and attachments.removeFileReference move the cursor to the correct position after modifying the prompt editor. Previously the cursor stayed at the start of inserted text, which broke the natural flow for plugin-driven file attachments like screenshot-paste. --- src/client/src/components/PiWebApp.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 95493d3..0250799 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1391,7 +1391,10 @@ export class PiWebApp extends LitElement { if (!editor) return; if (!editor.hasFocus) editor.focus(); const sel = editor.state.selection.main; - editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: text } }); + editor.dispatch({ + changes: { from: sel.from, to: sel.to, insert: text }, + selection: { anchor: sel.from + text.length }, + }); }, getText: () => { return this.promptEditor?.view?.state.doc.toString() ?? ""; @@ -1440,7 +1443,10 @@ export class PiWebApp extends LitElement { const editor = this.promptEditor?.view; if (editor) { const sel = editor.state.selection.main; - editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: reference } }); + editor.dispatch({ + changes: { from: sel.from, to: sel.to, insert: reference }, + selection: { anchor: sel.from + reference.length }, + }); } return reference; }, @@ -1463,6 +1469,7 @@ export class PiWebApp extends LitElement { if (index === -1) return; editor.dispatch({ changes: { from: index, to: index + reference.length, insert: "" }, + selection: { anchor: index }, }); }, }; From dde8675454def36d04901b94d1f9aeee86acd3b0 Mon Sep 17 00:00:00 2001 From: marcus Date: Sun, 14 Jun 2026 14:33:53 +0200 Subject: [PATCH 03/27] fix(plugin-api): federate workspace file mutations and trim PR scope --- .gitignore | 4 --- docs/plugins.md | 4 +-- package.json | 1 - scripts/postinstall.mjs | 35 ------------------- .../src/api/federatedRouteContract.test.ts | 3 ++ src/server/piWebPluginService.test.ts | 14 ++++---- src/shared/federatedRoutes.ts | 5 ++- 7 files changed, 16 insertions(+), 50 deletions(-) delete mode 100644 scripts/postinstall.mjs diff --git a/.gitignore b/.gitignore index c74fccc..052ea5e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,3 @@ dist/ # Local runtime attachment uploads (created by the chat composer "save to folder" mode). .pi-web/ - -# Local paste upload directory and temporary working docs. -.pi-paste/ -docs/tmp/ diff --git a/docs/plugins.md b/docs/plugins.md index 6706641..387ef6c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -976,13 +976,13 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis Check discovery: ```bash -curl http://localhost:8504/pi-web-plugins/manifest.json +curl http://127.0.0.1:8504/pi-web-plugins/manifest.json ``` Check a plugin module: ```bash -curl http://localhost:8504/pi-web-plugins/my-plugin/pi-web-plugin.js +curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js ``` Common issues: diff --git a/package.json b/package.json index 2783bc6..11e045c 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "prepublishOnly": "npm run verify", "publish:npm": "npm publish --access public", "prepare": "node scripts/install-git-hooks.mjs", - "postinstall": "node scripts/postinstall.mjs", "changeset": "changeset", "release:version": "changeset version", "changelog:status": "changeset status" diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs deleted file mode 100644 index 66b023e..0000000 --- a/scripts/postinstall.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import { chmodSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; - -/** - * node-pty 1.1.0 ships macOS prebuilds with `spawn-helper` files at 644 - * instead of 755, causing `posix_spawnp failed` at runtime. This script - * fixes permissions after install on Darwin platforms. - */ -function fixNodePtyPermissions() { - if (process.platform === "win32") return; - const prebuildsDir = join("node_modules", "node-pty", "prebuilds"); - let entries; - try { - entries = readdirSync(prebuildsDir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const helper = join(prebuildsDir, entry.name, "spawn-helper"); - let stats; - try { - stats = statSync(helper); - } catch { - continue; - } - if (!stats.isFile()) continue; - // 0o100 = regular file, 0o111 = owner/group/other execute - if ((stats.mode & 0o111) === 0) { - chmodSync(helper, 0o755); - } - } -} - -fixNodePtyPermissions(); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 059212e..faafbaf 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -37,6 +37,9 @@ describe("federated route contract", () => { ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)), ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)), ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)), + ignoreParseFailure(workspacesApi.writeWorkspaceFile("p 1", "w 1", "README.md", "hello", { overwrite: false }, machineId)), + ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)), + ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)), ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })), ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 1e11c73..7b4ec66 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -22,7 +22,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); await expect(service.manifest()).resolves.toEqual({ plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], @@ -41,7 +41,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] }); await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] }); @@ -74,7 +74,7 @@ describe("PiWebPluginService", () => { files: { "dist/pi-web-plugin.js": "export default { apiVersion: 1, name: 'Source Dev', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins).toEqual(expect.arrayContaining([ @@ -92,7 +92,7 @@ describe("PiWebPluginService", () => { await mkdir(join(tempDir, "plugins"), { recursive: true }); await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); @@ -135,7 +135,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]); @@ -167,7 +167,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]); @@ -181,7 +181,7 @@ describe("PiWebPluginService", () => { }); await writeFile(join(tempDir, "plugins", "escape.js"), "nope"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 3493d50..eab03cf 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -1,4 +1,4 @@ -export type FederatedHttpMethod = "GET" | "POST" | "DELETE"; +export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE"; export interface FederatedHttpRouteSpec { method: FederatedHttpMethod; @@ -15,6 +15,9 @@ export const FEDERATED_HTTP_ROUTES = [ { method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" }, + { method: "PUT", path: "/projects/:projectId/workspaces/:workspaceId/file" }, + { method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/file" }, + { method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/file/move" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" }, From 3742bcc9620d60d38f04601daa2ee6074dcecbff Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 23:16:37 +0200 Subject: [PATCH 04/27] 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. --- .changeset/plugin-api-completeness.md | 2 +- docs/plugins.md | 57 ++------------- src/client/src/components/PiWebApp.ts | 69 +------------------ src/client/src/components/PromptEditor.ts | 56 --------------- src/client/src/plugins/registry.test.ts | 8 --- src/client/src/plugins/types.ts | 10 --- src/plugin-api.ts | 26 ------- .../workspaces/fileContentService.test.ts | 16 +++++ src/server/workspaces/fileContentService.ts | 13 +++- 9 files changed, 34 insertions(+), 223 deletions(-) diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md index 0bff30c..5a3b3f3 100644 --- a/.changeset/plugin-api-completeness.md +++ b/.changeset/plugin-api-completeness.md @@ -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. diff --git a/docs/plugins.md b/docs/plugins.md index 387ef6c..d2a1814 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 0250799..a7e678e 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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); }, diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index ff0d7ee..0f8e24d 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -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(); - private readonly keydownHandlers = new Map(); protected override willUpdate(changed: PropertyValues) { 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()); }), diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 0c0d538..82edc7a 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -18,14 +18,6 @@ function createContext(statePatch: Partial = {}) { 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: { diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index d7cf32e..355b955 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -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; - getAttachedFiles(): string[]; - removeFileReference(path: string): void; } export interface PluginRuntimeContext { state: AppState; prompt: PluginPromptEditor; - attachments: PluginAttachments; piWebUnstable?: PiWebUnstableRuntimeContext; openActionPalette: () => void; focusPrompt: () => void; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 3c195bb..aa83cdc 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -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; - /** 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; diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index 232978b..8067933 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -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", () => { diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts index 58de7a2..f866e4e 100644 --- a/src/server/workspaces/fileContentService.ts +++ b/src/server/workspaces/fileContentService.ts @@ -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 }; From 9cc20d65fbc0544b0d3a66c2c791f575eebac973 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 23 Jun 2026 12:30:30 +0200 Subject: [PATCH 05/27] feat: add external path access allowlist --- .changeset/path-access-allowed-roots.md | 5 + AGENTS.md | 8 + src/client/src/api/clients.test.ts | 22 +- src/client/src/api/clients.ts | 9 +- .../src/api/federatedRouteContract.test.ts | 2 +- src/client/src/api/parsers.test.ts | 8 +- src/client/src/api/parsers.ts | 27 +- src/client/src/components/PiWebApp.ts | 10 +- src/client/src/components/PromptEditor.ts | 5 +- .../settings/SettingsGeneralPanel.ts | 14 + .../settings/settingsConfigDraft.test.ts | 31 +- .../settings/settingsConfigDraft.ts | 18 +- src/config.test.ts | 16 +- src/config.ts | 16 + src/server/app.test.ts | 84 +++- src/server/app.ts | 28 +- src/server/configRoutes.test.ts | 28 +- src/server/configRoutes.ts | 28 ++ src/server/workspaceExplorerRoutes.ts | 27 +- src/server/workspaces/effectivePathAccess.ts | 29 ++ .../workspaces/fileContentService.test.ts | 17 + src/server/workspaces/fileContentService.ts | 14 +- src/server/workspaces/fileSuggestions.test.ts | 217 +++++++++- src/server/workspaces/fileSuggestions.ts | 382 ++++++++++++++++-- src/server/workspaces/fileTreeService.test.ts | 16 + src/server/workspaces/fileTreeService.ts | 23 +- src/server/workspaces/imagePreviewService.ts | 11 +- .../workspaces/pathAccessPolicy.test.ts | 139 +++++++ src/server/workspaces/pathAccessPolicy.ts | 122 ++++++ src/server/workspaces/pathSafety.ts | 16 - .../workspaces/projectPiWebConfig.test.ts | 74 ++++ src/server/workspaces/projectPiWebConfig.ts | 71 ++++ src/shared/apiTypes.ts | 7 + src/shared/capabilities.ts | 3 +- src/shared/federatedRoutes.ts | 1 + 35 files changed, 1417 insertions(+), 111 deletions(-) create mode 100644 .changeset/path-access-allowed-roots.md create mode 100644 src/server/workspaces/effectivePathAccess.ts create mode 100644 src/server/workspaces/pathAccessPolicy.test.ts create mode 100644 src/server/workspaces/pathAccessPolicy.ts create mode 100644 src/server/workspaces/projectPiWebConfig.test.ts create mode 100644 src/server/workspaces/projectPiWebConfig.ts diff --git a/.changeset/path-access-allowed-roots.md b/.changeset/path-access-allowed-roots.md new file mode 100644 index 0000000..ae2fd8f --- /dev/null +++ b/.changeset/path-access-allowed-roots.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Allow configured external filesystem roots to be listed, read, configured from the global settings UI, and completed from absolute `@` path suggestions while keeping absolute paths denied by default, advertise workspace-scoped file suggestion support as a remote-machine capability, and use `fzf` when available to improve file/path completion filtering. diff --git a/AGENTS.md b/AGENTS.md index ad93e79..c503ccf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,11 @@ When working on this project, assume the session runtime owner is long-lived and If you make changes that affect `src/server/sessiond.ts`, session runtime ownership, the session daemon protocol, or any code path only loaded by the session daemon, inform the user that a manual restart of the session daemon is needed. Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path. + +## Configuration conventions + +- `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API. +- Global user/machine config lives at `$PI_WEB_CONFIG` or `~/.config/pi-web/config.json`. +- Project-local PI WEB core config should use one commit-able file: `/.pi-web/config.json`. +- Core features should add keys to these config files, not create one project file per feature. +- Plugins may own separate project config files, such as `.pi-web/tasks.json`. diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 90b865a..6fffd22 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; -import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; +import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; const workspace: Workspace = { id: "w/1", @@ -84,6 +84,26 @@ describe("session API compatibility", () => { }); }); +describe("machine-scoped file suggestion API", () => { + it("uses the workspace-scoped route when the caller has enabled workspace-scoped suggestions", async () => { + const fetchMock = stubJsonFetch([]); + + await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); + }); + + it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => { + const fetchMock = stubJsonFetch([]); + + await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); + }); +}); + describe("machine-scoped terminal command-run API", () => { it("deletes workspaces through the selected machine scope", async () => { const fetchMock = stubJsonFetch(commandRun); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 628cdd5..c98227f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -209,14 +209,21 @@ export interface FileSuggestionQueryOptions { mode?: "file" | "path" | undefined; scope?: "tracked" | "all" | undefined; machineId?: string | undefined; + projectId?: string | undefined; + workspaceId?: string | undefined; + workspaceScoped?: boolean | undefined; } export const filesApi = { files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => { - const params = new URLSearchParams({ cwd, q: query }); + const params = new URLSearchParams({ q: query }); if (options.kind !== undefined) params.set("kind", options.kind); if (options.mode !== undefined) params.set("mode", options.mode); if (options.scope !== undefined) params.set("scope", options.scope); + if (options.workspaceScoped === true && options.projectId !== undefined && options.workspaceId !== undefined) { + return request(`${machinePrefix(options.machineId)}/projects/${encodeURIComponent(options.projectId)}/workspaces/${encodeURIComponent(options.workspaceId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion)); + } + params.set("cwd", cwd); return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion)); }, }; diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 059212e..3173515 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -37,7 +37,7 @@ describe("federated route contract", () => { ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)), ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)), ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)), - ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })), + ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })), ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 7e8beff..375fe77 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -7,14 +7,14 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 53923cb..299daef 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -445,6 +445,8 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])), ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("plugins", optionalPlugins(record["plugins"])), + ...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])), + ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), }; @@ -453,10 +455,33 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined { if (value === undefined) return undefined; if (value === true) return true; - if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value; + if (isStringArray(value)) return value; throw new Error("Invalid PI WEB allowedHosts field"); } +function optionalPathAccess(value: unknown): PiWebConfigValues["pathAccess"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error("Invalid PI WEB pathAccess field"); + const allowedPaths = value["allowedPaths"]; + return { + ...optionalField("allowedPaths", optionalStringArray(allowedPaths, "pathAccess.allowedPaths")), + }; +} + +function optionalStringArray(value: unknown, field: string): string[] | undefined { + if (value === undefined) return undefined; + if (isNonEmptyStringArray(value)) return value; + throw new Error(`Invalid PI WEB ${field} field`); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== ""); +} + function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined { if (value === undefined) return undefined; if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field"); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 43dfa04..47014c3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1004,6 +1004,14 @@ export class PiWebApp extends LitElement { return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); } + private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { + if (machineId === "local") return true; + // COMPAT-CAP workspace.fileSuggestions: remote machines without this + // capability stay on the legacy cwd-based /files route. + const runtime = this.state.machineRuntimes[machineId]; + return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.workspaceFileSuggestions); + } + private archivedDeleteUnavailableMessage(): string { const machineName = this.state.selectedMachine?.name ?? "this machine"; return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; @@ -1738,7 +1746,7 @@ export class PiWebApp extends LitElement {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 27c3221..449c045 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -33,6 +33,9 @@ export class PromptEditor extends LitElement { @property() sessionId?: string; @property() cwd?: string; @property() machineId = "local"; + @property() projectId?: string; + @property() workspaceId?: string; + @property({ type: Boolean }) workspaceScopedFileSuggestions = false; @property({ type: Boolean }) canSteer = false; @property({ type: Boolean }) isCompacting = false; @property({ type: Boolean }) canStop = false; @@ -293,7 +296,7 @@ export class PromptEditor extends LitElement { ...(command.description === undefined ? {} : { description: command.description }), })); } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { - const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions); + const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) diff --git a/src/client/src/components/settings/SettingsGeneralPanel.ts b/src/client/src/components/settings/SettingsGeneralPanel.ts index b551b26..be2b10d 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.ts @@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement { Enter one host per line, or choose “Allow every host” to write true. + + ${this.renderEffectiveConfig()}