Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b22906d
fix(plugins): preserve scoped remote workspace file reads
Seraph-x Oct 22, 2026
9c71df2
fix(plugins): validate remote file reads on current main
Seraph-x Oct 22, 2026
a2d1825
Merge remote-tracking branch 'origin/main' into codex/review-pr1824
vastsa Oct 22, 2026
9c456e4
fix(plugins): preserve the latest upstream notification fix
Seraph-x Oct 22, 2026
6f51307
fix(plugin-sdk): bound plugin directory listings
vastsa Oct 22, 2026
393669e
Merge remote-tracking branch 'seraph/fix-remote-file-manager' into co…
vastsa Oct 22, 2026
41648cb
fix(plugins): validate the bundled remote file manager delivery path
Seraph-x Oct 22, 2026
ba3ff7e
fix(plugins): refresh paired delivery against the latest main
Seraph-x Oct 22, 2026
22774c0
fix(plugins): preserve upstream file reference and activity fixes
Seraph-x Oct 22, 2026
b36ef1f
fix(plugins): resolve main integration after AskTool updates
Seraph-x Oct 22, 2026
899073c
fix(plugins): integrate latest persistence repair from main
Seraph-x Oct 22, 2026
b926356
fix(chat): align error persistence regression with the current guard
Seraph-x Oct 22, 2026
f100c6c
fix(plugins): retain latest work-panel fixes in the integration candi…
Seraph-x Oct 22, 2026
29b6b63
fix(ci): identify stalled certificate probe phases on current main
Seraph-x Oct 22, 2026
632ff34
fix(ci): surface asynchronous certificate fixture assertions
Seraph-x Oct 22, 2026
0243c1b
fix(ci): align background cancellation checks with current Host logic
Seraph-x Oct 22, 2026
f9e4e02
fix(plugins): resolve integration conflicts with current main
Seraph-x Oct 23, 2026
1da0b1a
fix(plugins): retain both E2E scenarios after GenUI integration
Seraph-x Oct 23, 2026
5d25468
fix(plugins): connect bundled File Manager to scoped remote reads
Seraph-x Oct 26, 2026
6ba8d6b
fix(plugins): allow registered roots beyond browse boundary
Seraph-x Oct 27, 2026
3645dd2
Merge remote-tracking branch 'origin/main' into fix/remote-file-manager
Seraph-x Oct 27, 2026
830de6b
test(plugins): cover registered remote root delivery
Seraph-x Oct 27, 2026
ae90aa9
Merge branch 'main' into fix/remote-file-manager
vastsa Oct 27, 2026
5853af3
Merge remote-tracking branch 'origin/main' into fix/remote-file-manager
Seraph-x Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
# Generated diffs encode an empty context line as one significant space.
# Keep other whitespace diagnostics enabled; source-file checks are unchanged.
patches/*.patch whitespace=-blank-at-eol

# Keep the vendored changelog byte-identical to its pinned upstream source.
apps/desktop/resources/plugins/pi.file-manager/CHANGELOG.md whitespace=-blank-at-eol
10 changes: 10 additions & 0 deletions apps/desktop/electron/main/bootstrap/remote-hosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type RemoteHostConnectionStatus, type RemoteHostSyncProvidersRequest, type RemoteHostSyncProvidersResult, type SessionSummary,
type CommandShellOption, isCommandShellOption,
type WorkspaceFilesRequest,
type WorkspacePluginFilesRequest,
} from "@pi-desktop/shared";
import { wsClientTransport } from "@pi-desktop/racp";
import type { HostRpcPort } from "@pi-desktop/agent-host";
Expand Down Expand Up @@ -70,6 +71,7 @@ export interface RemoteHostsBoot {
abortWorkspaceTool(request: { hostKey: string; sessionId: string; toolCallId: string }): Promise<void>;
workspaceCommandShell(hostKey: string): Promise<CommandShellOption>;
workspaceFiles(hostKey: string, input: WorkspaceFilesRequest): Promise<unknown>;
workspacePluginFiles(hostKey: string, input: WorkspacePluginFilesRequest): Promise<unknown>;
readonly registry: RemoteHostRegistry;
}
let activeRemoteHostsBoot: RemoteHostsBoot | null = null;
Expand Down Expand Up @@ -423,6 +425,14 @@ export function createRemoteHostsBoot(options: BootRemoteHostsOptions): RemoteHo
if (connectedHost(hostKey) !== host) throw unavailable();
return result;
},
async workspacePluginFiles(hostKey, input) {
await ensureReady(hostKey);
const host = connectedHost(hostKey);
if (!host) throw unavailable();
const result = await host.adapter.client.request("workspace/pluginFiles", input);
if (connectedHost(hostKey) !== host) throw unavailable();
return result;
},
async workspaceCommandShell(hostKey) {
await ensureReady(hostKey);
const host = connectedHost(hostKey);
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/electron/main/plugin-host-process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,13 @@ function buildApi() {
...result,
bytes: normalizeBytes(result?.bytes),
})),
readPreview: (path) => call("fs.readPreview", [path]),
readPreview: (path, options) => call("fs.readPreview", options === undefined ? [path] : [path, options]),
openDefault: (path) => call("fs.openDefault", [path]),
openInBrowser: (path) => call("fs.openInBrowser", [path]),
reveal: (path) => call("fs.reveal", [path]),
writeText: (path, content) => call("fs.writeText", [path, content]),
glob: (pattern) => call("fs.glob", [pattern]),
list: (path) => call("fs.list", [path]),
list: (path, options) => call("fs.list", options === undefined ? [path] : [path, options]),
remove: (path) => call("fs.remove", [path]),
requestDirectory: () => call("fs.requestDirectory"),
},
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/electron/main/plugin-remote-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as Value from "typebox/value";
import { WorkspacePluginFilesResultSchema,
type WorkspacePluginFilesRequest, type WorkspacePluginFilesResult } from "@pi-desktop/shared";
import type { PluginFsReadOptions } from "@pi-desktop/plugin-sdk";

export type PluginRemoteFilesRequest = Omit<WorkspacePluginFilesRequest, "path"> & { workspacePath: string };
export type PluginRemoteFilesRead = (request: PluginRemoteFilesRequest) => Promise<unknown>;

const failure = (code: string, message: string) => Object.assign(new Error(message), { code });
export function pluginFsReadOptions(value: unknown): PluginFsReadOptions | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw failure("INVALID_ARGUMENT", "workspacePath must be a string");
return { workspacePath: value };
}
export function assertLocalPluginRoot(root: string | null): void {
if (root?.startsWith("remote-project:")) {
throw failure("UNSUPPORTED", "This file operation does not support remote workspaces");
}
}

/** Keep remote file admission and validation outside the plugin lifecycle orchestrator. */
export async function readRemotePluginFiles(context: {
workspacePath: string | null;
currentWorkspacePath: () => string | null;
isActive: () => boolean;
scope: readonly string[];
read?: PluginRemoteFilesRead;
audit: (entry: Record<string, unknown>) => void;
}, operation: "list" | "preview", ref: string, options?: PluginFsReadOptions): Promise<WorkspacePluginFilesResult | null> {
const root = context.workspacePath;
if (options !== undefined && (!options || typeof options !== "object" || typeof options.workspacePath !== "string")) {
throw failure("INVALID_ARGUMENT", "workspacePath must be a string");
}
if (options && options.workspacePath !== root) throw failure("WORKSPACE_CHANGED", "The file view's workspace has changed");
if (!root?.startsWith("remote-project:")) return null;
const api = operation === "list" ? "fs.list" : "fs.readPreview";
try {
if (typeof ref !== "string" || /[\u0000-\u001f]/.test(ref) || /^(?:[\\/]|[a-zA-Z]:)/.test(ref)) {
throw failure("INVALID_ARGUMENT", "Remote plugin paths must be root-relative");
}
const parts = ref.replaceAll("\\", "/").split("/").filter(part => part && part !== ".");
if (parts.includes("..")) throw failure("INVALID_ARGUMENT", "Remote plugin paths must stay inside the workspace");
const normalizedRef = parts.join("/");
if (!context.read) throw failure("UNSUPPORTED", "Remote plugin file access is unavailable; upgrade Desktop and pi-host");
const result = await context.read({ workspacePath: root, operation, ref: normalizedRef, scope: [...context.scope] });
if (!context.isActive()) throw failure("PLUGIN_UNLOADED", "The plugin was unloaded during the file request");
if (context.currentWorkspacePath() !== root) throw failure("WORKSPACE_CHANGED", "The file view's workspace has changed");
if (!Value.Check(WorkspacePluginFilesResultSchema, result)
|| (operation === "list") !== (result.kind === "list")) {
throw failure("INVALID_RESPONSE", "Remote Host returned an invalid plugin file response");
}
if (result.kind === "list") {
for (const entry of result.entries) {
const expected = normalizedRef ? `${normalizedRef}/${entry.name}` : entry.name;
if (/[\\/\u0000-\u001f]/.test(entry.name) || [".", ".."].includes(entry.name) || entry.path !== expected) {
throw failure("INVALID_RESPONSE", "Remote Host returned an invalid directory entry");
}
}
}
context.audit({ api, ok: true, path: normalizedRef, remote: true, ts: Date.now() });
return result;
} catch (error) {
const record = error as { code?: string; errorCode?: string };
context.audit({ api, ok: false, path: ref, remote: true, errorCode: record.errorCode ?? record.code ?? "UNKNOWN", ts: Date.now() });
throw error;
}
}
59 changes: 50 additions & 9 deletions apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { randomUUID } from "node:crypto";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { pluginNetFetch, parseFetchRedirect, type PluginFetchTransport } from "./plugin-net-fetch";
import type { LoadedSkillDocument } from "./skill-document";
import { assertLocalPluginRoot, pluginFsReadOptions, readRemotePluginFiles, type PluginRemoteFilesRead } from "./plugin-remote-files";
import { getModuleDirectory } from "./module-path";
import {
busTopicAllowed,
Expand All @@ -28,6 +29,7 @@ import {
normalizeFsPath,
parseNetDomains,
parseSkillFrontmatter,
PLUGIN_FS_LIST_MAX_ENTRIES,
pluginMcpToolKey,
pluginSkillId,
pluginThemeId,
Expand Down Expand Up @@ -56,6 +58,7 @@ import {
type PluginFsMode,
type PluginFsPolicy,
type PluginFsRule,
type PluginFsReadOptions,
type PluginLlmContext,
type PluginManifest,
type PluginModelInfo,
Expand Down Expand Up @@ -325,6 +328,7 @@ export type PluginHostServices = {
* visible workspace -- for a panel call or an unknown session.
*/
getWorkspacePathForSession?: (sessionId: string) => string | null;
readRemoteWorkspaceFiles?: PluginRemoteFilesRead;
/** The set of `contributes.agentExtensions` modules changed (load/unload). */
agentExtensionsChanged?: () => void;
getLocale?: () => string;
Expand Down Expand Up @@ -799,8 +803,6 @@ export const MAX_COMPLETE_SYSTEM_CHARS = 32 * 1024;
export const MAX_COMPLETE_MESSAGE_CHARS = 200_000;
/** Kept from the pre-scope implementation: a listing is not a search index. */
const MAX_GLOB_MATCHES = 500;
/** Entries returned for one directory. A tree is walked lazily, not dumped. */
const MAX_LIST_ENTRIES = 1000;
/** Maximum bytes one plugin range call may cross the broker with. */
const MAX_FS_READ_RANGE_BYTES = 8 * 1024 * 1024;
/**
Expand Down Expand Up @@ -2503,7 +2505,7 @@ export class PluginRuntime {
context?.droppedPath,
);
case "fs.readPreview":
return api.fs.readPreview(String(payload?.path ?? ""));
return api.fs.readPreview(String(payload?.path ?? ""), pluginFsReadOptions(payload?.workspacePath));
case "fs.openDefault":
await api.fs.openDefault(String(payload?.path ?? ""));
return { ok: true };
Expand All @@ -2519,7 +2521,7 @@ export class PluginRuntime {
case "fs.glob":
return api.fs.glob(String(payload?.pattern ?? "*"));
case "fs.list":
return api.fs.list(String(payload?.path ?? ""));
return api.fs.list(String(payload?.path ?? ""), pluginFsReadOptions(payload?.workspacePath));
// Deleting is not reachable from a panel; choosing a directory is, and
// has to be, because a "pick a folder" button is panel UI by nature.
case "fs.requestDirectory":
Expand Down Expand Up @@ -4983,6 +4985,33 @@ export class PluginRuntime {
return scoped ?? this.services.getWorkspacePath();
}

private remoteFsRead(loaded: LoadedPlugin, operation: "list" | "preview", ref: string, options?: PluginFsReadOptions) {
this.assertPermission(loaded, "fs.read");
const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] };
return readRemotePluginFiles({
workspacePath: this.fsRoot(loaded, rule), currentWorkspacePath: () => this.fsRoot(loaded, rule),
isActive: () => this.loaded.get(loaded.manifest.id) === loaded,
scope: rule.scope, read: this.services.readRemoteWorkspaceFiles,
audit: (entry) => this.services.audit?.({ ...entry, pluginId: loaded.manifest.id }),
}, operation, ref, options);
}

private assertFileWorkspace(loaded: LoadedPlugin, options?: PluginFsReadOptions): void {
if (!options) return;
if (this.loaded.get(loaded.manifest.id) !== loaded) throw apiError("PLUGIN_UNLOADED", "The plugin was unloaded during the file request");
const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] };
if (this.fsRoot(loaded, rule) !== options.workspacePath) throw apiError("WORKSPACE_CHANGED", "The file view's workspace has changed");
}

private captureFileWorkspace(loaded: LoadedPlugin, options?: PluginFsReadOptions): PluginFsReadOptions {
this.assertPermission(loaded, "fs.read");
this.assertFileWorkspace(loaded, options);
const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] };
const workspacePath = this.fsRoot(loaded, rule);
if (!workspacePath) throw apiError("NOT_FOUND", "No workspace is open");
return { workspacePath };
}

/**
* Resolve one file request and decide whether it may proceed.
*
Expand Down Expand Up @@ -5038,6 +5067,7 @@ export class PluginRuntime {
}
const rule: PluginFsRule = loaded.fsPolicy[mode] ?? { root: "workspace", scope: [] };
const root = this.fsRoot(loaded, rule);
assertLocalPluginRoot(root);
if (!root) {
throw apiError(
"NOT_FOUND",
Expand Down Expand Up @@ -5115,6 +5145,7 @@ export class PluginRuntime {
loaded: LoadedPlugin,
requestPath: string,
): Promise<{ full: string; rel: string; root: string } | null> {
assertLocalPluginRoot(this.fsRoot(loaded, loaded.fsPolicy.read ?? { root: "workspace", scope: [] }));
if (!isAbsolute(String(requestPath ?? ""))) return null;
const rule: PluginFsRule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] };
if (rule.root !== "workspace") return null;
Expand Down Expand Up @@ -5831,7 +5862,11 @@ export class PluginRuntime {
await handle.close().catch(() => {});
}
},
readPreview: async (pathFromRoot: string) => {
readPreview: async (pathFromRoot: string, options?: PluginFsReadOptions) => {
const admitted = this.captureFileWorkspace(loaded, options);
const remote = await this.remoteFsRead(loaded, "preview", pathFromRoot, admitted);
if (remote && remote.kind !== "list") return remote;
this.assertFileWorkspace(loaded, admitted);
const { full, rel } = await this.resolveFsRequest(
loaded,
"read",
Expand All @@ -5849,6 +5884,7 @@ export class PluginRuntime {
throw apiError("INVALID_ARGUMENT", "only files can be previewed");
}
const preview = await previewFile(full, rel);
this.assertFileWorkspace(loaded, admitted);
this.services.audit?.({
pluginId,
api: "fs.readPreview",
Expand Down Expand Up @@ -6000,11 +6036,15 @@ export class PluginRuntime {
* paths, denied names, skipped heavy directories — because a listing is
* a read: what a plugin may not open, it may not learn the name of.
*/
list: async (pathFromRoot: string) => {
list: async (pathFromRoot: string, options?: PluginFsReadOptions) => {
const admitted = this.captureFileWorkspace(loaded, options);
const remote = await this.remoteFsRead(loaded, "list", pathFromRoot, admitted);
if (remote?.kind === "list") return remote.entries;
this.assertFileWorkspace(loaded, admitted);
this.assertPermission(loaded, "fs.read");
const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] };
const root = this.fsRoot(loaded, rule);
if (!root) throw apiError("NOT_FOUND", "No workspace is open");
const root = admitted.workspacePath;
assertLocalPluginRoot(root);
const rel = normalizeFsPath(String(pathFromRoot ?? ""));
if (rel.split("/").includes("..")) {
throw apiError("INVALID_ARGUMENT", "path must stay inside the root");
Expand All @@ -6028,7 +6068,7 @@ export class PluginRuntime {
mtimeMs?: number;
}> = [];
for (const name of names.sort()) {
if (entries.length >= MAX_LIST_ENTRIES) break;
if (entries.length >= PLUGIN_FS_LIST_MAX_ENTRIES) break;
const childRel = rel ? `${rel}/${name}` : name;
const full = join(dir, name);
if (isDeniedFsPath(childRel)) continue;
Expand Down Expand Up @@ -6091,6 +6131,7 @@ export class PluginRuntime {
this.assertPermission(loaded, "fs.read");
const rule = loaded.fsPolicy.read ?? { root: "workspace", scope: [] };
const root = this.fsRoot(loaded, rule);
assertLocalPluginRoot(root);
if (!root) throw apiError("NOT_FOUND", "No workspace is open");
const matches: string[] = [];
const visit = (dir: string, rel = "") => {
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/electron/main/remote/plugin-workspace-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ProjectGroupRecord, WorkspacePluginFilesRequest } from "@pi-desktop/shared";
import type { PluginRemoteFilesRead } from "../plugin-remote-files";

type Host = { call<T>(method: string, input?: unknown): Promise<T> };
type Boot = { workspacePluginFiles(hostKey: string, input: WorkspacePluginFilesRequest): Promise<unknown> };
const failure = (code: string, message: string) => Object.assign(new Error(message), { code });

/** The plugin supplies an admitted identity; only durable Host-owned records choose the machine. */
export function createPluginWorkspaceReader(deps: {
getHost: () => Host | null;
getBoot: () => Boot | null;
}): PluginRemoteFilesRead {
return async ({ workspacePath, operation, ref, scope }) => {
const owner = deps.getHost(); const boot = deps.getBoot();
if (!owner || !boot) throw failure("HOST_UNAVAILABLE", "Remote workspace Host is unavailable");
const currentBinding = async () => {
const { groups } = await owner.call<{ groups: ProjectGroupRecord[] }>("project.groups.list");
if (deps.getHost() !== owner || deps.getBoot() !== boot) throw failure("HOST_UNAVAILABLE", "Workspace Host was replaced");
const remote = groups.find(group => group.primaryPath === workspacePath)?.remote;
if (!workspacePath.startsWith("remote-project:") || !remote) throw failure("NOT_FOUND", "Remote workspace binding is unavailable");
return { hostKey: remote.hostKey, path: remote.path };
};
const binding = await currentBinding();
const result = await boot.workspacePluginFiles(binding.hostKey, { path: binding.path, operation, ref, scope });
const next = await currentBinding();
if (binding.hostKey !== next.hostKey || binding.path !== next.path) {
throw failure("WORKSPACE_CHANGED", "Remote workspace binding changed during the file request");
}
return result;
};
}
8 changes: 7 additions & 1 deletion apps/desktop/electron/main/remote/racp-remote-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,13 @@ export function createRacpRemoteHostClient(
async request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
// Offline errors remain offline errors; capabilities belong to the live handshake.
if (racp.state === "connected") {
if (method === "workspace/files") requireWorkspace("workspaceFiles");
if (method === "workspace/pluginFiles") {
if (!racp.initialized?.capabilities.workspacePluginFiles) {
throw Object.assign(new Error("Upgrade pi-host to a matching Desktop release with plugin file browsing support"), {
errorCode: ErrorCodes.HOST_VERSION_MISMATCH, retriable: false,
});
}
} else if (method === "workspace/files") requireWorkspace("workspaceFiles");
else if (["workspace/toolExecute", "workspace/toolAbort", "workspace/toolShell"].includes(method)) {
requireWorkspace("workspaceTools");
}
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/main/services/plugin-services.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { dialog, globalShortcut, shell, type BrowserWindow } from "electron";
import { join } from "node:path";
import { getActiveRemoteHostsBoot } from "../bootstrap/remote-hosts";
import { createPluginWorkspaceReader } from "../remote/plugin-workspace-files";
import {
IPC,
ErrorCodes,
Expand Down Expand Up @@ -706,6 +708,7 @@ export function createPluginServices({
browserHost.setChromeSurface(surface);
};
plugins.setServices({
readRemoteWorkspaceFiles: createPluginWorkspaceReader({ getHost, getBoot: getActiveRemoteHostsBoot }),
/**
* The richer workspace payload, so `pi.workspace.get` and the
* `workspace:changed` event both expose the open project's folder roots
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/electron/main/workspace-roots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export async function refreshProjectGroups(
*/
export function pluginWorkspaceInfo(path: string | null): PluginWorkspaceInfo | null {
if (!path) return null;
if (path.startsWith("remote-project:")) {
const group = groups?.find((candidate) => candidate.primaryPath === path);
if (group?.remote) return {
path, name: group.name, ...workspaceRootsFor(path),
remote: { path: group.remote.path, readOnly: true },
};
}
const name = path.split(/[\\/]/).filter(Boolean).at(-1) || path;
return { path, name, ...workspaceRootsFor(path) };
}
Loading
Loading