Merge pull request #25 from jmfederico/cleanup/plugin-api-scope

feat: plugin API — workspace file mutations & prompt editor access
This commit is contained in:
Federico Jaramillo Martinez
2026-06-24 09:23:53 +02:00
committed by GitHub
20 changed files with 1079 additions and 27 deletions
+1 -1
View File
@@ -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";
+63
View File
@@ -167,6 +167,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<Response>;
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
+30 -1
View File
@@ -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<DeleteWorkspaceFileResponse> => {
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 = {
@@ -37,6 +37,10 @@ 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(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)),
+1 -1
View File
@@ -1,6 +1,6 @@
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
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 => ({}));
+29 -1
View File
@@ -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<string, unknown> {
@@ -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");
+42 -1
View File
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, 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";
@@ -1216,6 +1216,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;
},
};
}
@@ -1387,9 +1402,35 @@ 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 },
selection: { anchor: sel.from + text.length },
});
},
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) };
},
};
}
private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
prompt: this.createPromptEditor(),
piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
@@ -117,6 +117,11 @@ 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;
}
private renderCompactStatus() {
const status = this.status;
if (status === undefined) return null;
+34 -4
View File
@@ -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,11 @@ function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
const context: PluginRuntimeContext = {
state: { ...initialAppState(), ...statePatch },
prompt: {
insertText: vi.fn(),
getText: vi.fn(() => ""),
getSelection: vi.fn(() => null),
},
piWebUnstable: {
terminalCommandRuns: {
runCommand: vi.fn(),
@@ -335,7 +340,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<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
registry.register({
id: "example",
@@ -545,7 +550,7 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
}
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) };
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
@@ -562,7 +567,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 +618,31 @@ function testStatus(patch: Partial<SessionStatus> = {}): 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" };
}
+11 -1
View File
@@ -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<FileContentResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
}
export interface WorkspaceHost {
@@ -83,8 +86,15 @@ 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;
}
export interface PluginRuntimeContext {
state: AppState;
prompt: PluginPromptEditor;
piWebUnstable?: PiWebUnstableRuntimeContext;
openActionPalette: () => void;
focusPrompt: () => void;
+28 -1
View File
@@ -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,20 @@ 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;
}
export interface PluginRuntimeContext {
state: PluginRuntimeState;
prompt: PluginPromptEditor;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
@@ -109,7 +126,17 @@ export interface Workspace {
}
export interface WorkspaceFiles {
/** Read a file from the workspace. Works for local and federated machines. */
readFile(path: string): Promise<FileContentResponse>;
/** 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<WriteWorkspaceFileResponse>;
/** 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<DeleteWorkspaceFileResponse>;
/** 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<MoveWorkspaceFileResponse>;
}
export type WorkspacePanelFiles = WorkspaceFiles;
+229
View File
@@ -557,6 +557,235 @@ describe("buildApp", () => {
expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
});
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<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
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 });
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
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 });
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);
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
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 });
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);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
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);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
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);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
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);
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<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
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" },
});
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 });
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 });
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);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().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<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
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" },
});
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" });
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
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);
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);
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
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" },
});
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);
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);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
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);
const noParamsResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
});
expect(noParamsResponse.statusCode).toBe(400);
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
});
});
interface CapturedSessionDaemonRequest {
+53 -7
View File
@@ -1,19 +1,22 @@
import type { FastifyInstance } from "fastify";
import type { ProjectService } from "./projects/projectService.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
import type { PiWebConfigService } from "./configRoutes.js";
import type { ProjectService } from "./projects/projectService.js";
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
export interface WorkspaceExplorerRouteOptions {
config?: Pick<PiWebConfigService, "read">;
}
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
registerWorkspaceFileContentParsers(app);
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);
@@ -32,6 +35,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 writeOptions: WriteWorkspaceFileOptions = {
createDirs: request.query.createDirs !== "false",
overwrite: request.query.overwrite !== "false",
};
return await writeWorkspaceFile(context.root, request.query.path, request.body, writeOptions);
} 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);
@@ -61,3 +98,12 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
}
});
}
function registerWorkspaceFileContentParsers(app: FastifyInstance): void {
// Fastify's default parser only handles JSON; workspace file writes need to
// accept text and arbitrary binary payloads. This route module is registered
// for both local aliases, so parser registration must tolerate repeats.
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_request, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/u, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
}
@@ -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[] = [];
@@ -113,3 +114,262 @@ 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");
});
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", () => {
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();
});
});
+109 -2
View File
@@ -1,7 +1,9 @@
import { open, stat } from "node:fs/promises";
import type { FileContentResponse, PiWebPathAccessConfig } 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, PiWebPathAccessConfig, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
import { imageMimeTypeForPath } from "./imagePreviewService.js";
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
const MAX_BYTES = 512 * 1024;
@@ -39,6 +41,111 @@ async function readFilePrefix(target: string, bytesToRead: number): Promise<Buff
}
}
export async function writeWorkspaceFile(rootPath: string, path: string | undefined, content: Buffer, options: WriteWorkspaceFileOptions = {}): Promise<WriteWorkspaceFileResponse> {
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<DeleteWorkspaceFileResponse> {
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 { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
try {
// 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(realTarget);
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<MoveWorkspaceFileResponse> {
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);
+17 -1
View File
@@ -1,6 +1,18 @@
import { realpath } from "node:fs/promises";
import { isAbsolute, join, relative, sep } from "node:path";
export async function resolveInsideWorkspace(rootPath: string, relativePath: string | undefined): Promise<{ root: string; target: string; relativePath: string }> {
const requested = normalizeRelativePath(relativePath);
const root = await realpath(rootPath);
const joined = join(root, requested);
const target = await realpath(joined).catch((error: unknown) => {
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error("Path does not exist");
throw error;
});
ensureInside(root, target);
return { root, target, relativePath: requested };
}
export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> {
const requested = normalizeRelativePath(relativePath);
const root = await realpath(rootPath);
@@ -18,7 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string {
return parts.join("/");
}
function ensureInside(root: string, target: string): void {
export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
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");
+29
View File
@@ -315,6 +315,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 {
+4 -1
View File
@@ -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/files" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },