From b22906d55fb03fb81356478eeb8e548048b8e245 Mon Sep 17 00:00:00 2001 From: Seraph-x Date: Thu, 22 Oct 2026 21:24:56 +0800 Subject: [PATCH 1/7] fix(plugins): preserve scoped remote workspace file reads Remote project identities are not local paths. Route plugin directory and classified preview reads through a separate owner-only Host service that preserves scope, canonical containment and credential protections. Capture workspace admission before awaiting and reject stale bindings or unloaded plugins, including legacy SDK calls. Keep older Hosts compatible with existing remote operations and report missing preview support clearly. Refs #1711 --- .../electron/main/bootstrap/remote-hosts.ts | 10 + .../electron/main/plugin-host-process.mjs | 4 +- .../electron/main/plugin-remote-files.ts | 67 ++++++ apps/desktop/electron/main/plugin-runtime.ts | 54 ++++- .../main/remote/plugin-workspace-files.ts | 31 +++ .../main/remote/racp-remote-host-client.ts | 8 +- .../electron/main/services/plugin-services.ts | 3 + apps/desktop/electron/main/workspace-roots.ts | 7 + .../desktop/test/plugin-remote-files.test.mjs | 123 +++++++++++ .../test/plugin-workspace-reader.test.mjs | 28 +++ .../test/racp-remote-host-client.test.mjs | 2 +- apps/desktop/test/workspace-roots.test.mjs | 11 + apps/pi-host/package.json | 1 + apps/pi-host/src/app.ts | 1 + apps/pi-host/src/host-operations.ts | 7 + .../src/plugin-workspace-files.test.ts | 98 +++++++++ apps/pi-host/src/plugin-workspace-files.ts | 106 +++++++++ apps/pi-host/tsconfig.json | 1 + docs/adr/README.md | 1 + docs/adr/plugin-remote-workspace-reads.md | 49 +++++ .../19-remote-agent-control-protocol.md | 12 + docs/spec/06-delivery/04-e2e-test-plan.md | 20 ++ docs/spec/07-plugins/03-plugin-api.md | 20 +- docs/zh-CN/adr/index.md | 1 + .../19-remote-agent-control-protocol.md | 9 + .../spec/06-delivery/04-e2e-test-plan.md | 14 ++ docs/zh-CN/spec/07-plugins/03-plugin-api.md | 15 +- package.json | 1 + packages/plugin-sdk/src/index.ts | 11 +- packages/racp/src/host-operations.ts | 2 + packages/racp/src/operations.ts | 7 + packages/racp/src/racp.test.ts | 24 ++ packages/racp/src/server.ts | 1 + packages/shared/fixtures/racp.schema.json | 175 +++++++++++++++ packages/shared/src/racp.test.ts | 18 ++ packages/shared/src/racp.ts | 29 +++ packages/shared/src/types/plugins.ts | 2 + pnpm-lock.yaml | 3 + scripts/e2e-remote-file-manager.mjs | 208 ++++++++++++++++++ 39 files changed, 1167 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/electron/main/plugin-remote-files.ts create mode 100644 apps/desktop/electron/main/remote/plugin-workspace-files.ts create mode 100644 apps/desktop/test/plugin-remote-files.test.mjs create mode 100644 apps/desktop/test/plugin-workspace-reader.test.mjs create mode 100644 apps/pi-host/src/plugin-workspace-files.test.ts create mode 100644 apps/pi-host/src/plugin-workspace-files.ts create mode 100644 docs/adr/plugin-remote-workspace-reads.md create mode 100644 scripts/e2e-remote-file-manager.mjs diff --git a/apps/desktop/electron/main/bootstrap/remote-hosts.ts b/apps/desktop/electron/main/bootstrap/remote-hosts.ts index 95fd2e3ca0..10eb2d4c4f 100644 --- a/apps/desktop/electron/main/bootstrap/remote-hosts.ts +++ b/apps/desktop/electron/main/bootstrap/remote-hosts.ts @@ -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"; @@ -70,6 +71,7 @@ export interface RemoteHostsBoot { abortWorkspaceTool(request: { hostKey: string; sessionId: string; toolCallId: string }): Promise; workspaceCommandShell(hostKey: string): Promise; workspaceFiles(hostKey: string, input: WorkspaceFilesRequest): Promise; + workspacePluginFiles(hostKey: string, input: WorkspacePluginFilesRequest): Promise; readonly registry: RemoteHostRegistry; } let activeRemoteHostsBoot: RemoteHostsBoot | null = null; @@ -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); diff --git a/apps/desktop/electron/main/plugin-host-process.mjs b/apps/desktop/electron/main/plugin-host-process.mjs index 89c91c41d3..35143e5fac 100644 --- a/apps/desktop/electron/main/plugin-host-process.mjs +++ b/apps/desktop/electron/main/plugin-host-process.mjs @@ -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"), }, diff --git a/apps/desktop/electron/main/plugin-remote-files.ts b/apps/desktop/electron/main/plugin-remote-files.ts new file mode 100644 index 0000000000..9eecc451dc --- /dev/null +++ b/apps/desktop/electron/main/plugin-remote-files.ts @@ -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 & { workspacePath: string }; +export type PluginRemoteFilesRead = (request: PluginRemoteFilesRequest) => Promise; + +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) => void; +}, operation: "list" | "preview", ref: string, options?: PluginFsReadOptions): Promise { + 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; + } +} diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index 437d1455e6..e575685e65 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -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, @@ -56,6 +57,7 @@ import { type PluginFsMode, type PluginFsPolicy, type PluginFsRule, + type PluginFsReadOptions, type PluginLlmContext, type PluginManifest, type PluginModelInfo, @@ -325,6 +327,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; @@ -2502,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 }; @@ -2518,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": @@ -4978,6 +4981,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. * @@ -5033,6 +5063,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", @@ -5110,6 +5141,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; @@ -5826,7 +5858,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", @@ -5844,6 +5880,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", @@ -5995,11 +6032,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"); @@ -6086,6 +6127,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 = "") => { diff --git a/apps/desktop/electron/main/remote/plugin-workspace-files.ts b/apps/desktop/electron/main/remote/plugin-workspace-files.ts new file mode 100644 index 0000000000..cf56ef1db9 --- /dev/null +++ b/apps/desktop/electron/main/remote/plugin-workspace-files.ts @@ -0,0 +1,31 @@ +import type { ProjectGroupRecord, WorkspacePluginFilesRequest } from "@pi-desktop/shared"; +import type { PluginRemoteFilesRead } from "../plugin-remote-files"; + +type Host = { call(method: string, input?: unknown): Promise }; +type Boot = { workspacePluginFiles(hostKey: string, input: WorkspacePluginFilesRequest): Promise }; +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; + }; +} diff --git a/apps/desktop/electron/main/remote/racp-remote-host-client.ts b/apps/desktop/electron/main/remote/racp-remote-host-client.ts index 37dfcba3c5..4ab994b3bc 100644 --- a/apps/desktop/electron/main/remote/racp-remote-host-client.ts +++ b/apps/desktop/electron/main/remote/racp-remote-host-client.ts @@ -208,7 +208,13 @@ export function createRacpRemoteHostClient( async request(method: string, params?: unknown, timeoutMs?: number): Promise { // 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"); } diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index dcd0e1e431..027f64e538 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -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, @@ -701,6 +703,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 diff --git a/apps/desktop/electron/main/workspace-roots.ts b/apps/desktop/electron/main/workspace-roots.ts index 0a7e30b66a..6ffb568525 100644 --- a/apps/desktop/electron/main/workspace-roots.ts +++ b/apps/desktop/electron/main/workspace-roots.ts @@ -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) }; } diff --git a/apps/desktop/test/plugin-remote-files.test.mjs b/apps/desktop/test/plugin-remote-files.test.mjs new file mode 100644 index 0000000000..b277f45a74 --- /dev/null +++ b/apps/desktop/test/plugin-remote-files.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { register } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const data = await mkdtemp(join(tmpdir(), "pi-remote-plugin-data-")); +process.env.PI_DESKTOP_DATA_DIR = data; +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); +const { PluginRuntime } = await import("../electron/main/plugin-runtime.ts"); +test.after(() => rm(data, { recursive: true, force: true })); + +const REMOTE = "remote-project:fixture"; +const preview = { kind: "text", content: "remote source", size: 13, mtimeMs: 1234 }; +async function harness(t, { permissions = ["fs.read"], scope = ["docs/**"], read } = {}) { + const directory = await mkdtemp(join(tmpdir(), "pi-remote-plugin-")); + await writeFile(join(directory, "manifest.json"), JSON.stringify({ schemaVersion: 1, id: "test.remote-files", + name: "Remote fixture", version: "0.0.1", main: "main.js", permissions, + ...(permissions.includes("fs.read") ? { fs: { read: { root: "workspace", scope } } } : {}) })); + await writeFile(join(directory, "main.js"), `module.exports = { onPanelInvoke(_channel, input) { + return pi.fs.readPreview(input.path, { workspacePath: input.workspacePath }); + } };`); + const requests = []; const audits = []; + let workspace = REMOTE; + const runtime = new PluginRuntime({ + hostEntry: new URL("../electron/main/plugin-host-process.mjs", import.meta.url).pathname, + spawnProcess({ entry }) { + const child = fork(entry, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] }); + return { postMessage: message => { if (child.connected) child.send(message); }, + onMessage: handler => child.on("message", handler), onExit: handler => child.on("exit", code => handler(code ?? 0)), + kill: () => child.kill() }; + }, + getWorkspacePath: () => workspace, + readRemoteWorkspaceFiles: async request => { requests.push(request); return read ? read(request) : preview; }, + audit: entry => audits.push(entry), + }); + await runtime.loadFromPath(directory); + t.after(async () => { await runtime.unload("test.remote-files"); await rm(directory, { recursive: true, force: true }); }); + const invoke = (channel, input) => runtime.invokePanelBridge("test.remote-files", channel, input); + return { runtime, requests, audits, invoke, directory, select: path => { workspace = path; } }; +} + +test("real plugin process reads remote bytes with its captured identity and declared scope", { timeout: 60_000 }, async t => { + const h = await harness(t); + assert.deepEqual(await h.invoke("fixture.preview", { path: "docs/a.md", workspacePath: REMOTE }), preview); + assert.deepEqual(h.requests, [{ workspacePath: REMOTE, operation: "preview", ref: "docs/a.md", scope: ["docs/**"] }]); + assert(h.audits.some(entry => entry.api === "fs.readPreview" && entry.ok)); + await writeFile(join(h.directory, "local.txt"), "local control"); + h.select(h.directory); + // A changed project can never reinterpret an old remote request as a local path. + await assert.rejects(h.invoke("fixture.preview", { path: "local.txt", workspacePath: REMOTE }), { code: "WORKSPACE_CHANGED" }); + assert.equal(h.requests.length, 1); +}); + +test("remote listing preserves its SDK shape through the panel bridge", { timeout: 60_000 }, async t => { + const entries = [{ name: "a.md", path: "docs/a.md", isDirectory: false, size: 13, mtimeMs: 1234 }]; + const h = await harness(t, { read: async () => ({ kind: "list", entries }) }); + assert.deepEqual(await h.invoke("fs.list", { path: "docs", workspacePath: REMOTE }), entries); + assert.deepEqual(h.requests[0], { workspacePath: REMOTE, operation: "list", ref: "docs", scope: ["docs/**"] }); +}); + +test("remote reads reject missing permission, invalid replies and transport errors", { timeout: 60_000 }, async t => { + const noGrant = await harness(t, { permissions: [] }); + await assert.rejects(noGrant.invoke("fs.readPreview", { path: "docs/a.md" }), { code: "PERMISSION_DENIED" }); + assert.equal(noGrant.requests.length, 0); + const invalid = await harness(t, { read: async () => ({ kind: "text", size: 13, mtimeMs: 1234 }) }); + await assert.rejects(invalid.invoke("fs.readPreview", { path: "docs/a.md" }), { code: "INVALID_RESPONSE" }); + const offline = await harness(t, { read: async () => { throw Object.assign(new Error("fixture disconnected"), { errorCode: "HOST_DISCONNECTED" }); } }); + await assert.rejects(offline.invoke("fs.readPreview", { path: "docs/a.md" }), /disconnected/); + assert(offline.audits.some(entry => entry.api === "fs.readPreview" && entry.ok === false)); +}); + +test("late remote reads are rejected after project changes or plugin unload", { timeout: 60_000 }, async t => { + for (const change of ["workspace", "unload"]) { + const started = Promise.withResolvers(); const finish = Promise.withResolvers(); + const h = await harness(t, { read: () => { started.resolve(); return finish.promise; } }); + const pending = h.invoke("fs.readPreview", { path: "docs/a.md", workspacePath: REMOTE }); + const rejected = assert.rejects(pending, { code: change === "workspace" ? "WORKSPACE_CHANGED" : "PLUGIN_UNLOADED" }); + await started.promise; + if (change === "workspace") h.select(h.directory); + else await h.runtime.unload("test.remote-files"); + finish.resolve(preview); + await rejected; + } +}); + +test("local-only plugin actions explicitly reject a remote root", { timeout: 60_000 }, async t => { + const h = await harness(t, { permissions: ["fs.read", "fs.write"], scope: ["**"] }); + for (const [channel, input] of [["fs.readText", { path: "docs/a.md" }], ["fs.glob", { pattern: "**" }], + ["fs.writeText", { path: "docs/a.md", content: "must not write locally" }], ["fs.reveal", { path: "docs/a.md" }]]) { + await assert.rejects(h.invoke(channel, input), { code: "UNSUPPORTED" }); + } + assert.equal(h.requests.length, 0); +}); + +test("an expected local workspace cannot be replaced during asynchronous admission", { timeout: 60_000 }, async t => { + const h = await harness(t); + const other = await mkdtemp(join(tmpdir(), "pi-other-project-")); + t.after(() => rm(other, { recursive: true, force: true })); + await writeFile(join(other, "other.txt"), "another project"); + h.select(h.directory); + const pending = h.invoke("fs.list", { path: "", workspacePath: h.directory }); + h.select(other); + await assert.rejects(pending, { code: "WORKSPACE_CHANGED" }); +}); + +test("legacy reads retain their admitted local workspace without new SDK options", { timeout: 60_000 }, async t => { + const h = await harness(t); + const other = await mkdtemp(join(tmpdir(), "pi-legacy-project-")); + t.after(() => rm(other, { recursive: true, force: true })); + await writeFile(join(other, "other.txt"), "another project"); + for (const next of [other, REMOTE]) { + for (const [channel, path] of [["fs.list", ""], ["fs.readPreview", "other.txt"]]) { + h.select(h.directory); + const pending = h.invoke(channel, { path }); + h.select(next); + await assert.rejects(pending, { code: "WORKSPACE_CHANGED" }); + } + } + assert.equal(h.requests.length, 0); +}); diff --git a/apps/desktop/test/plugin-workspace-reader.test.mjs b/apps/desktop/test/plugin-workspace-reader.test.mjs new file mode 100644 index 0000000000..81c3a76461 --- /dev/null +++ b/apps/desktop/test/plugin-workspace-reader.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +test("plugin workspace transport resolves an admitted identity and rejects changed bindings", { timeout: 60_000 }, async () => { + const module = await import("../electron/main/remote/plugin-workspace-files.ts"); + assert.equal(typeof module.createPluginWorkspaceReader, "function", "Desktop must bind plugin reads to the durable remote project"); + const calls = []; let binding = { hostKey: "ssh", path: "/repo" }; + const owner = { call: async method => { + assert.equal(method, "project.groups.list"); + return { groups: [{ primaryPath: "remote-project:one", remote: { ...binding } }] }; + } }; + let active = owner; + const started = Promise.withResolvers(); const finish = Promise.withResolvers(); + const boot = { workspacePluginFiles: async (key, request) => { calls.push({ key, request }); started.resolve(); return finish.promise; } }; + const read = module.createPluginWorkspaceReader({ getHost: () => active, getBoot: () => boot }); + const input = { workspacePath: "remote-project:one", operation: "preview", ref: "docs/a.md", scope: ["docs/**"] }; + const pending = read(input); + await started.promise; + binding = { hostKey: "other", path: "/other" }; + finish.resolve({ kind: "text", content: "old remote", size: 10, mtimeMs: 1 }); + await assert.rejects(pending, error => error.code === "WORKSPACE_CHANGED"); + assert.deepEqual(calls, [{ key: "ssh", request: { path: "/repo", operation: "preview", ref: "docs/a.md", scope: ["docs/**"] } }]); + await assert.rejects(read({ ...input, workspacePath: "remote-project:missing" }), { code: "NOT_FOUND" }); + active = null; + await assert.rejects(read(input), { code: "HOST_UNAVAILABLE" }); +}); diff --git a/apps/desktop/test/racp-remote-host-client.test.mjs b/apps/desktop/test/racp-remote-host-client.test.mjs index 40100ec375..2c08472d55 100644 --- a/apps/desktop/test/racp-remote-host-client.test.mjs +++ b/apps/desktop/test/racp-remote-host-client.test.mjs @@ -52,7 +52,7 @@ test("legacy Agent remains usable but unsupported workspace calls fail before di try { await adapter.connect(); assert.ok(Array.isArray((await adapter.client.request("session/list")).sessions)); - for (const method of ["workspace/toolShell", "workspace/toolExecute", "workspace/toolAbort", "workspace/files"]) { + for (const method of ["workspace/toolShell", "workspace/toolExecute", "workspace/toolAbort", "workspace/files", "workspace/pluginFiles"]) { await assert.rejects(adapter.client.request(method, {}), error => error.errorCode === "HOST_VERSION_MISMATCH" && /upgrade/i.test(error.message)); } diff --git a/apps/desktop/test/workspace-roots.test.mjs b/apps/desktop/test/workspace-roots.test.mjs index 32bc77b56e..4b34112d76 100644 --- a/apps/desktop/test/workspace-roots.test.mjs +++ b/apps/desktop/test/workspace-roots.test.mjs @@ -44,6 +44,17 @@ function resetTo(groups) { rememberProjectGroups(groups); } +test("remote plugin metadata preserves the opaque identity and separates the display path", () => { + resetTo([{ ...group, id: "ssh-project", name: "Remote repository", primaryPath: "remote-project:ssh-project", + roots: [{ path: "remote-project:ssh-project", name: "Remote repository", position: 0 }], + remote: { hostKey: "private-host-key", path: "/home/user/repository" } }]); + const info = pluginWorkspaceInfo("remote-project:ssh-project"); + assert.equal(info.path, "remote-project:ssh-project"); + assert.equal(info.name, "Remote repository"); + assert.deepEqual(info.remote, { path: "/home/user/repository", readOnly: true }); + assert.equal(JSON.stringify(info).includes("private-host-key"), false); +}); + test("a workspace inside a group reports every folder, primary first", () => { resetTo([group]); const payload = workspaceRootsFor("C:\\work\\alpha"); diff --git a/apps/pi-host/package.json b/apps/pi-host/package.json index 803bca1cef..26b466ee3c 100644 --- a/apps/pi-host/package.json +++ b/apps/pi-host/package.json @@ -20,6 +20,7 @@ "@pi-desktop/agent-host": "workspace:*", "@pi-desktop/agent-runtime": "workspace:*", "@pi-desktop/host-runtime": "workspace:*", + "@pi-desktop/plugin-sdk": "workspace:*", "@pi-desktop/racp": "workspace:*", "@pi-desktop/shared": "workspace:*", "ws": "8.21.1" diff --git a/apps/pi-host/src/app.ts b/apps/pi-host/src/app.ts index ffdbc27396..843cc2d21e 100644 --- a/apps/pi-host/src/app.ts +++ b/apps/pi-host/src/app.ts @@ -247,6 +247,7 @@ export async function startPiHost(config: PiHostConfig, options: { log?: HostLog getHost, runtime, browseRoot: config.browseRoot, + dataDir: config.dataDir, disposeSession: async (sessionId) => { const sidecar = getSidecar(); if (!sidecar) return; diff --git a/apps/pi-host/src/host-operations.ts b/apps/pi-host/src/host-operations.ts index 6bc825201d..170cdbca17 100644 --- a/apps/pi-host/src/host-operations.ts +++ b/apps/pi-host/src/host-operations.ts @@ -1,4 +1,5 @@ import { createWorkspaceFiles } from "./workspace-files.js"; +import { createWorkspacePluginFiles } from "./plugin-workspace-files.js"; import { createWorkspaceTools } from "./workspace-tools.js"; import { readdir, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; @@ -46,6 +47,8 @@ export type HostOperationsDeps = { revokeDevice?: (deviceId: string) => Promise; /** Root the folder picker may not leave; defaults to the user's home directory. */ browseRoot?: string; + /** Host credentials and persistence must never be exposed through plugin reads. */ + dataDir?: string; }; function requireHost(getHost: () => HostRpc | null): HostRpc { @@ -256,6 +259,10 @@ export function createHostOperations(deps: HostOperationsDeps): Omit { + await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +async function fixture() { + const home = await realpath(await mkdtemp(join(tmpdir(), "pi-plugin-remote-"))); + temporary.push(home); + const root = join(home, "repo"); + const data = join(home, "host-data"); + await mkdir(join(root, "docs"), { recursive: true }); + await mkdir(data); + await writeFile(join(root, "docs", "guide.md"), "remote guide\n"); + await writeFile(join(root, "other.txt"), "outside declared scope"); + await writeFile(join(root, ".env"), "private fixture"); + await writeFile(join(data, "tokens.json"), "private host fixture"); + const operations = createHostOperations({ + getHost: () => null, + runtime: { compact: async () => ({ accepted: true }), isBusy: () => false }, + browseRoot: home, + dataDir: data, + }); + expect(operations.workspacePluginFiles, "Host must offer a separate scoped plugin read service").toBeDefined(); + const read = (operation: "list" | "preview", ref = "", scope = ["**"], path = root) => + operations.workspacePluginFiles!.read({ path, ref, operation, scope }); + return { home, root, data, read }; +} + +it("lists nested remote directories and previews text with real metadata", { timeout: 60_000 }, async () => { + const { root, read } = await fixture(); + const listed = await read("list", "docs"); + expect(listed).toMatchObject({ kind: "list", entries: [{ name: "guide.md", path: "docs/guide.md", isDirectory: false, size: 13 }] }); + const preview = await read("preview", "docs/guide.md"); + expect(preview).toMatchObject({ kind: "text", content: "remote guide\n", size: 13, mtimeMs: expect.any(Number) }); + expect((preview as { mtimeMs: number }).mtimeMs).toBeGreaterThan(0); + expect(await read("list")).not.toHaveProperty("entries.0.name", ".env"); + expect(JSON.stringify(await read("list"))).not.toContain('".env"'); + await writeFile(join(root, "image.png"), Buffer.from([137, 80, 78, 71])); + expect(await read("preview", "image.png")).toMatchObject({ kind: "image", dataUrl: "data:image/png;base64,iVBORw==", size: 4 }); +}); + +it("enforces scope on both the requested and canonical file", { timeout: 60_000 }, async () => { + const { root, read } = await fixture(); + await symlink(join(root, "other.txt"), join(root, "docs", "alias.txt")); + expect(await read("list", "docs", ["docs/**"])).toMatchObject({ entries: [{ name: "guide.md" }] }); + expect(JSON.stringify(await read("list", "docs", ["docs/**"]))).not.toContain("alias.txt"); + await expect(read("preview", "docs/alias.txt", ["docs/**"])).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + await expect(read("preview", "other.txt", ["docs/**"])).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + expect(await read("list", "docs", [])).toMatchObject({ entries: [] }); +}); + +it("refuses credentials, escaped links, protected Host data and browse-root escapes", { timeout: 60_000 }, async () => { + const { home, root, data, read } = await fixture(); + await symlink(join(root, ".env"), join(root, "docs", "credential.txt")); + await symlink(data, join(root, "host-link")); + const outside = await realpath(await mkdtemp(join(tmpdir(), "pi-plugin-outside-"))); + temporary.push(outside); + await writeFile(join(outside, "other.txt"), "outside"); + await symlink(join(outside, "other.txt"), join(root, "escape.txt")); + for (const ref of [".env", "docs/credential.txt", "escape.txt", "host-link/tokens.json", "../host-data/tokens.json", "/etc/passwd", "C:\\secrets.txt", "docs/\u0000bad"]) { + await expect(read("preview", ref)).rejects.toMatchObject({ code: expect.stringMatching(/FORBIDDEN|INVALID_ARGUMENT/) }); + } + for (const path of [data, outside]) { + await expect(read("list", "", ["**"], path)).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + } + expect(JSON.stringify(await read("list"))).not.toMatch(/escape.txt|host-link/); + expect(JSON.stringify(await read("list", "docs"))).not.toContain("credential.txt"); + expect(home).not.toBe(outside); +}); + +it("returns explicit oversized/binary results and missing-file errors", { timeout: 60_000 }, async () => { + const { root, read } = await fixture(); + await writeFile(join(root, "large.txt"), "x".repeat(512 * 1024 + 1)); + await writeFile(join(root, "binary.dat"), Buffer.from([1, 0, 2])); + expect(await read("preview", "large.txt")).toMatchObject({ kind: "tooLarge", size: 512 * 1024 + 1 }); + expect(await read("preview", "binary.dat")).toMatchObject({ kind: "binary", size: 3 }); + await expect(read("preview", "missing.txt")).rejects.toMatchObject({ code: "REMOTE_PATH_NOT_FOUND" }); + await expect(read("preview", "docs")).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); +}); + +it("refuses credential directories used as the workspace root, including aliases", { timeout: 60_000 }, async () => { + const { home, read } = await fixture(); + const credentials = join(home, ".aws"); + const alias = join(home, "credential-alias"); + await mkdir(credentials); + await writeFile(join(credentials, "credentials"), "private fixture"); + await symlink(credentials, alias); + for (const path of [credentials, alias]) { + await expect(read("list", "", ["**"], path)).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + await expect(read("preview", "credentials", ["**"], path)).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + } +}); diff --git a/apps/pi-host/src/plugin-workspace-files.ts b/apps/pi-host/src/plugin-workspace-files.ts new file mode 100644 index 0000000000..925b6ce119 --- /dev/null +++ b/apps/pi-host/src/plugin-workspace-files.ts @@ -0,0 +1,106 @@ +import { readdir, realpath, stat } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { RacpError } from "@pi-desktop/agent-host"; +import { previewFile } from "@pi-desktop/host-runtime"; +import { isDeniedFsPath, isFsPathInScope } from "@pi-desktop/plugin-sdk"; +import type { WorkspacePluginFilesRequest, WorkspacePluginFilesResult } from "@pi-desktop/shared"; +import type { RacpHostOperations } from "@pi-desktop/racp"; + +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".venv", "__pycache__"]); +const forbidden = () => new RacpError("REMOTE_PATH_FORBIDDEN", "path is outside the plugin's readable workspace"); +const inside = (root: string, target: string) => { + const rel = relative(root, target); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +}; +const relativePath = (root: string, target: string) => relative(root, target).split(sep).join("/"); + +function normalizeRef(ref: string): string { + if (typeof ref !== "string" || /[\u0000-\u001f]/.test(ref) || /^(?:[\\/]|[a-zA-Z]:)/.test(ref)) { + throw new RacpError("INVALID_ARGUMENT", "plugin paths must be root-relative"); + } + const parts = ref.replaceAll("\\", "/").split("/").filter((part) => part && part !== "."); + if (parts.includes("..")) throw forbidden(); + return parts.join("/"); +} + +async function canonical(path: string): Promise { + try { return await realpath(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new RacpError("REMOTE_PATH_NOT_FOUND", "workspace path does not exist"); + } + throw error; + } +} + +/** A separate read-only boundary: ordinary owner workspace reads are not plugin grants. */ +export function createWorkspacePluginFiles(options: { + browseRoot: string; + protectedPaths: readonly string[]; +}): NonNullable { + return { async read(input: WorkspacePluginFilesRequest): Promise { + if (!isAbsolute(input.path) || /[\u0000-\u001f]/.test(input.path) + || !Array.isArray(input.scope) || !input.scope.every((pattern) => typeof pattern === "string")) { + throw new RacpError("INVALID_ARGUMENT", "invalid plugin workspace request"); + } + const ref = normalizeRef(input.ref); + const [root, browseRoot] = await Promise.all([canonical(input.path), canonical(options.browseRoot)]); + const protectedRoots = await Promise.all(options.protectedPaths.map(async (path) => { + try { return await realpath(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return resolve(path); + throw error; + } + })); + const protectedPath = (path: string) => protectedRoots.some((guarded) => inside(guarded, path)); + if (!inside(browseRoot, root) || protectedPath(root) || isDeniedFsPath(root)) throw forbidden(); + if (!(await stat(root)).isDirectory()) throw new RacpError("INVALID_ARGUMENT", "workspace is not a directory"); + + const authorize = async (requested: string, file: boolean) => { + if (isDeniedFsPath(requested)) throw forbidden(); + const target = await canonical(join(root, requested)); + const actual = relativePath(root, target); + if (!inside(root, target) || protectedPath(target) || isDeniedFsPath(target) || isDeniedFsPath(actual)) throw forbidden(); + if (file && (!isFsPathInScope(requested, input.scope) || !isFsPathInScope(actual, input.scope))) throw forbidden(); + return target; + }; + const target = await authorize(ref, input.operation === "preview"); + const info = await stat(target); + if (input.operation === "preview") { + if (!info.isFile()) throw new RacpError("INVALID_ARGUMENT", "only regular files can be previewed"); + const preview = await previewFile(target, ref); + const metadata = { size: preview.size, mtimeMs: info.mtimeMs }; + if (preview.kind === "text") { + if (typeof preview.content !== "string") throw new RacpError("INTERNAL", "text preview is missing its content"); + return { ...metadata, kind: "text", content: preview.content }; + } + if (preview.kind === "image") { + if (typeof preview.dataUrl !== "string") throw new RacpError("INTERNAL", "image preview is missing its data"); + return { ...metadata, kind: "image", dataUrl: preview.dataUrl }; + } + return { ...metadata, kind: preview.kind }; + } + if (input.operation !== "list" || !info.isDirectory()) { + throw new RacpError("INVALID_ARGUMENT", "only directories can be listed"); + } + const entries: Extract["entries"] = []; + for (const entry of (await readdir(target, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + const childRef = ref ? `${ref}/${entry.name}` : entry.name; + try { + const child = await authorize(childRef, false); + const childInfo = await stat(child); + if (childInfo.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) entries.push({ name: entry.name, path: childRef, isDirectory: true }); + } else if (childInfo.isFile()) { + await authorize(childRef, true); + entries.push({ name: entry.name, path: childRef, isDirectory: false, size: childInfo.size, mtimeMs: childInfo.mtimeMs }); + } + } catch (error) { + // Forbidden or vanished entries are absent from a plugin's readable tree. + if (error instanceof RacpError && ["REMOTE_PATH_FORBIDDEN", "REMOTE_PATH_NOT_FOUND"].includes(error.code)) continue; + throw error; + } + } + return { kind: "list", entries }; + } }; +} diff --git a/apps/pi-host/tsconfig.json b/apps/pi-host/tsconfig.json index 88e6660e6f..f22c612620 100644 --- a/apps/pi-host/tsconfig.json +++ b/apps/pi-host/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../../packages/agent-host" }, { "path": "../../packages/agent-runtime" }, { "path": "../../packages/host-runtime" }, + { "path": "../../packages/plugin-sdk" }, { "path": "../../packages/racp" } ] } diff --git a/docs/adr/README.md b/docs/adr/README.md index eba1586a07..90bed55257 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ Each ADR includes: | ID | Title | Status | |---|---|---| +| plugin-remote-workspace-reads | [Plugin reads in remote workspaces](plugin-remote-workspace-reads.md) | Implemented candidate | | provider-metadata-client-certificate-retry | [Provider metadata recovery from optional client certificates](provider-metadata-client-certificate-retry.md) | Implemented candidate | | device-local-project-lists-in-config-sync | [Device-local project lists in configuration sync](device-local-project-lists-in-config-sync.md) | Accepted | | s3-only-configuration-backup-sync | [S3-only encrypted configuration backup and synchronization](s3-only-configuration-backup-sync.md) | Accepted; supersedes WebDAV decisions in 0300 / 0301 | diff --git a/docs/adr/plugin-remote-workspace-reads.md b/docs/adr/plugin-remote-workspace-reads.md new file mode 100644 index 0000000000..838ac0aca6 --- /dev/null +++ b/docs/adr/plugin-remote-workspace-reads.md @@ -0,0 +1,49 @@ +# ADR: Plugin reads in remote workspaces + +## Status + +Implemented candidate + +## Context + +Issue #1711 reports a File Manager that keeps loading in an SSH workspace. +Remote projects have an opaque Desktop identity, not a local filesystem root. +The upstream plugin currently passes that identity to its own Node filesystem. +Existing owner-only `workspace/files` reads do not enforce a plugin's declared +file scope, so directly forwarding plugin requests to that service would widen +their access. + +## Decision + +Extend the existing `fs.list` and `fs.readPreview` channels with remote reads. +Desktop admits each call against the plugin permission, scope and current root, +resolves the remote binding from host-owned project records, and sends a closed +`workspace/pluginFiles` request. The remote Host advertises this optional +capability and accepts it only from paired owners. It validates canonical +containment, protected Host data, the credential deny-list and the declared +scope before reading. Requested and canonical file names must both match scope. + +The SDK accepts an optional expected workspace identity. All calls, including +legacy calls without this argument, capture their root before asynchronous +admission. Workspace changes, binding replacement and plugin unload invalidate +late results. Remote errors never select a local file of the same name. + +Remote workspace metadata adds a display path and a read-only marker; the +opaque primary identity stays unchanged. This increment supports directories +and classified text/image previews only. Other filesystem channels reject +opaque remote roots explicitly. Existing local operations retain their policy. + +## Consequences + +Older Hosts remain compatible with existing remote functionality. A missing +plugin-files capability reports an explicit upgrade error for these new reads. +This service carries neither models nor provider credentials. + +The independently maintained File Manager must consume the SDK instead of +Node filesystem calls for remote projects. Its release and the bundled plugin +update are separate delivery steps; this Host change alone does not close +#1711. Pinned bundled sources and their upstream checksums remain unchanged. + +The alternative of exposing the broader owner file service was rejected +because it does not preserve plugin policy. Remote editing, filesystem search, +watching and local application launching are outside this bounded repair. diff --git a/docs/spec/03-runtime/19-remote-agent-control-protocol.md b/docs/spec/03-runtime/19-remote-agent-control-protocol.md index 077a77948e..a58141ae02 100644 --- a/docs/spec/03-runtime/19-remote-agent-control-protocol.md +++ b/docs/spec/03-runtime/19-remote-agent-control-protocol.md @@ -639,6 +639,18 @@ Legacy remote-Agent operations above remain unchanged. local global defaults before remote root instructions. Nested traversal and remote global defaults are not supported by this operation. +`workspace/pluginFiles` is a separate owner-only, read-only operation, advertised +by optional capability `workspacePluginFiles`. It accepts only +`{ path, operation: "list" | "preview", ref, scope: string[] }`; no Host key, +write payload or external absolute `ref` is accepted. The Host checks its browse +root, protected data, canonical containment, credential denials, and requested +and canonical scope before delivery. Results are a closed `kind` union: `list` +with root-relative entries, or `text`/`image`/`binary`/`tooLarge` with real size +and `mtimeMs`. Text and images use the existing 512 KiB / 5 MiB preview caps. +Desktop derives the remote root from its admitted project binding and rejects +late results after a binding or connection lifecycle change. This operation +does not widen `workspace/files` or grant remote mutation rights. + `workspaceTools` and `workspaceFiles` are optional advertised capabilities; an older Host fails with CAPABILITY_UNAVAILABLE/METHOD_NOT_FOUND rather than falling back to the Desktop filesystem. Tools are not automatically replayed diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 433e758e52..9c5dac8a18 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -18483,3 +18483,23 @@ host-created files. The full app's file-preview viewer is covered separately. `composer-suggestion-state.test.mjs`, `composer-suggestion-relay.test.mjs`, and `plugin-composer-suggestion.test.mjs`; the rendered journey remains a manual/Electron check when this surface changes. + + +### E2E-REMOTE-FILE-MANAGER: Scoped remote plugin reads + +Run `node scripts/e2e-remote-file-manager.mjs `. +The directory must contain the companion upstream plugin fix and its rebuilt +`views/` (or the unpacked official PluginPack artifact). +`PI_FILE_MANAGER_QA_DIR` may point to an existing plugin `tests/electron` +installation providing Playwright; `PI_ELECTRON_BINARY` selects an existing +compatible Electron runtime. `PI_E2E_ARTIFACTS` optionally preserves screenshots. + +The isolated production plugin process and `file://` view use Desktop's actual +SDK broker, durable-binding reader, loopback RACP transport and Host filesystem +service. Only the fixture's project-record lookup and authentication bootstrap +are supplied by the harness. Verify nested listings, text/image bytes, hidden +credentials, read-only editor/save/menu controls, visible disconnect errors and +refresh recovery, rejected late previews and ancestor opens after a project +switch, and restored local editing/Browser/editor detection. No model, user +profile or external SSH server participates. Real SSH transport and native +Windows/Linux behavior require their separate platform acceptance. diff --git a/docs/spec/07-plugins/03-plugin-api.md b/docs/spec/07-plugins/03-plugin-api.md index 041dea6304..ef571dfc79 100644 --- a/docs/spec/07-plugins/03-plugin-api.md +++ b/docs/spec/07-plugins/03-plugin-api.md @@ -348,18 +348,19 @@ pi.fs.readRange( length: number, grantId?: string, ): Promise<{ bytes: Uint8Array; totalSize: number }> -pi.fs.readPreview(pathFromRoot: string): Promise<{ +pi.fs.readPreview(pathFromRoot: string, options?: { workspacePath: string }): Promise<{ kind: "text" | "image" | "binary" | "tooLarge" content?: string // UTF-8 when kind is "text" dataUrl?: string // data URL when kind is "image" size: number + mtimeMs?: number // Remote previews include the Host's file timestamp. }> pi.fs.openDefault(pathFromRoot: string): Promise pi.fs.openInBrowser(pathFromRoot: string): Promise pi.fs.reveal(pathFromRoot: string): Promise pi.fs.writeText(pathFromRoot: string, content: string): Promise pi.fs.glob(pattern: string): Promise -pi.fs.list(pathFromRoot: string): Promise`。 +目录需包含配套插件修复和重新构建的 `views/`,也可使用官方 PluginPack 产物的解包目录。 +`PI_FILE_MANAGER_QA_DIR` 可指向已有、提供 Playwright 的插件 `tests/electron` 环境; +`PI_ELECTRON_BINARY` 选择兼容的已有 Electron;`PI_E2E_ARTIFACTS` 可保留截图。 + +隔离的真实插件进程与生产 `file://` 视图经过 Desktop SDK 网关、持久绑定读取器、 +本机回环 RACP 传输和真实 Host 文件服务。仅项目记录查询和认证启动由夹具提供。 +检查嵌套目录、文本/图片、凭据隐藏、编辑器/保存/菜单只读、断线错误和刷新恢复, +以及切换项目后拒绝延迟预览和祖先打开请求,并恢复本地编辑、Browser 和编辑器探测。 +不使用模型、用户 profile 或外部 SSH;真实 SSH 及 Windows/Linux 原生行为需另行平台验收。 diff --git a/docs/zh-CN/spec/07-plugins/03-plugin-api.md b/docs/zh-CN/spec/07-plugins/03-plugin-api.md index ad5808c5e2..f82fceeb3a 100644 --- a/docs/zh-CN/spec/07-plugins/03-plugin-api.md +++ b/docs/zh-CN/spec/07-plugins/03-plugin-api.md @@ -294,7 +294,7 @@ pi.workspace.get(): Promise<{ } | null> pi.fs.readText(pathFromRoot: string): Promise -pi.fs.readPreview(pathFromRoot: string): Promise<{ +pi.fs.readPreview(pathFromRoot: string, options?: { workspacePath: string }): Promise<{ kind: "text" | "image" | "binary" | "tooLarge" content?: string // UTF-8 when kind is "text" dataUrl?: string // data URL when kind is "image" @@ -304,7 +304,7 @@ pi.fs.openDefault(pathFromRoot: string): Promise pi.fs.reveal(pathFromRoot: string): Promise pi.fs.writeText(pathFromRoot: string, content: string): Promise pi.fs.glob(pattern: string): Promise -pi.fs.list(pathFromRoot: string): Promise 使用相同的 `fs.read` 检查,拒绝目录,并返回 `text`(上限 512 KiB)、`image` (上限 5 MiB,data URL)、`binary` 或 `tooLarge`。插件不会收到绝对路径。 +SSH 项目的 `workspace.get` 保留不透明的 `remote-project:` 身份,并新增 +`remote: { path, readOnly: true }`。`remote.path` 仅用于显示,不能交给本地文件系统。 +`fs.list` 与 `fs.readPreview` 经配对 owner Host 的可选 `workspacePluginFiles` +能力读取;Host 检查规范路径包含关系、受保护目录、凭据拒绝列表与插件声明的 scope。 +远程预览还返回真实 `mtimeMs`;其他文件通道仍限本地并明确拒绝远程身份。 +缺少能力返回 `HOST_VERSION_MISMATCH`,不会改读 Desktop 文件系统。 + +可选 `workspacePath` 应为视图捕获的工作区身份,不匹配返回 `WORKSPACE_CHANGED`。 +包括未传新参数的旧调用在内,所有读取均在首次等待前捕获根;根、远程绑定或插件 +生命周期改变后丢弃过期结果。参见[决策](/adr/plugin-remote-workspace-reads)。 + `fs.openDefault` 使用操作系统默认关联应用打开一个已存在的文件。它与 `fs.readText` 使用相同的 `fs.read` 根目录、符号链接、受保护路径、拒绝列表和范围检查; 目录会被拒绝。主机会记录这次操作。路径默认相对根目录;只有「本项目已注册的另一个文件夹根」 diff --git a/package.json b/package.json index 5cb73df90e..bb0b63ee16 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "test:e2e:collaboration": "node scripts/e2e-session-collaboration.mjs", "test:e2e:session-completion": "node scripts/e2e-session-completion.mjs", "test:e2e:remote-host": "node scripts/e2e-remote-host.mjs", + "test:e2e:remote-file-manager": "node scripts/e2e-remote-file-manager.mjs", "test:e2e:remote-mvp": "node scripts/e2e-remote-mvp.mjs", "test:e2e:remote-mvp-ui": "node scripts/e2e-remote-mvp-ui.mjs", "test:e2e:shortcut-settings": "node scripts/test-shortcut-settings.mjs", diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 5665452360..3eac52a873 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1186,8 +1186,13 @@ export type PluginFsPreview = { /** Base64 data URL when kind is `"image"`. */ dataUrl?: string; size: number; + /** Available for brokered remote previews. */ + mtimeMs?: number; }; +/** Reject a file response if the workspace changed while a view was loading it. */ +export type PluginFsReadOptions = { workspacePath: string }; + /** * The appearance the host is currently showing. Mirrors `PluginAppearance` in * the desktop's plugin panel chrome; keep the two shapes identical. @@ -1309,7 +1314,7 @@ export type PluginHostApi = { }; }; workspace: { - get: () => Promise<{ path: string; name: string } | null>; + get: () => Promise<{ path: string; name: string; remote?: { path: string; readOnly: true } } | null>; }; /** Reviewed host operations shared with the local MCP control plane. */ desktop: { @@ -1368,7 +1373,7 @@ export type PluginHostApi = { * a data URL; text is capped; binary and oversized files are reported * without dumping their bytes. */ - readPreview: (pathFromRoot: string) => Promise; + readPreview: (pathFromRoot: string, options?: PluginFsReadOptions) => Promise; /** Open an existing readable file with the operating system's default app. */ openDefault: (pathFromRoot: string) => Promise; /** Open an existing readable HTML file in the bundled work-panel Browser. */ @@ -1383,7 +1388,7 @@ export type PluginHostApi = { * are always listed so the tree stays navigable; files are filtered by the * declared read scope, and heavy or protected directories are skipped. */ - list: (pathFromRoot: string) => Promise; + list: (pathFromRoot: string, options?: PluginFsReadOptions) => Promise; remove: (pathFromRoot: string) => Promise; /** * Ask the user to point at a directory, which becomes the root for this diff --git a/packages/racp/src/host-operations.ts b/packages/racp/src/host-operations.ts index f0f0afac1a..a12e853ef0 100644 --- a/packages/racp/src/host-operations.ts +++ b/packages/racp/src/host-operations.ts @@ -5,6 +5,7 @@ import type { RacpProjectSummary, WorkspaceDiff, WorkspaceToolExecute, WorkspaceToolAbort, WorkspaceToolResult, CommandShellOption, WorkspaceFilesRequest, + WorkspacePluginFilesRequest, WorkspacePluginFilesResult, } from "@pi-desktop/shared"; /** @@ -88,6 +89,7 @@ export type RacpHostOperations = { workspace: RacpWorkspaceAccess; terminal?: RacpTerminalAccess; workspaceFiles?: { read(input: WorkspaceFilesRequest): Promise }; + workspacePluginFiles?: { read(input: WorkspacePluginFilesRequest): Promise }; workspaceTools?: { execute(input: WorkspaceToolExecute, owner: { deviceId: string; connectionId: string }): Promise; abort(input: WorkspaceToolAbort, owner: { deviceId: string; connectionId: string }): Promise; diff --git a/packages/racp/src/operations.ts b/packages/racp/src/operations.ts index 72647054da..2b1b3adb19 100644 --- a/packages/racp/src/operations.ts +++ b/packages/racp/src/operations.ts @@ -3,6 +3,7 @@ import { RacpError } from "@pi-desktop/agent-host"; import { RacpApprovalResponseSchema, WorkspaceToolExecuteSchema, WorkspaceToolAbortSchema, WorkspaceFilesRequestSchema, + WorkspacePluginFilesRequestSchema, RacpCursorSchema, RacpInputResponseSchema, RacpRequestContextSchema, @@ -170,6 +171,12 @@ export function createOperations(): Map { } return context.operations.workspaceFiles.read(check(WorkspaceFilesRequestSchema, params)); }); + handlers.set("workspace/pluginFiles", async (context, params) => { + if (!context.operations.workspacePluginFiles || !context.capabilities.workspacePluginFiles) { + throw new RacpError("CAPABILITY_UNAVAILABLE", "this Host does not offer plugin workspace reads"); + } + return context.operations.workspacePluginFiles.read(check(WorkspacePluginFilesRequestSchema, params)); + }); handlers.set("workspace/toolExecute", async (context, params) => requireWorkspaceTools(context).execute(check(WorkspaceToolExecuteSchema, params), toolOwner(context))); handlers.set("workspace/toolAbort", async (context, params) => { diff --git a/packages/racp/src/racp.test.ts b/packages/racp/src/racp.test.ts index 40736e5317..9a1c907d2a 100644 --- a/packages/racp/src/racp.test.ts +++ b/packages/racp/src/racp.test.ts @@ -10,6 +10,30 @@ function envelope(sessionId: string, turnId: string, event: AgentEventEnvelope[" const context = (requestId: string, idempotencyKey?: string) => ({ requestId, ...(idempotencyKey ? { idempotencyKey } : {}) }); +it("routes scoped plugin reads only for owners and supported Hosts", async () => { + const calls: unknown[] = []; + const h = await harness({ operations: { workspacePluginFiles: { async read(input) { + calls.push(input); return { kind: "text", content: "remote", size: 6, mtimeMs: 123 }; + } } } }); + const owner = await h.connect(OWNER_TOKEN); + const viewer = await h.connect(VIEWER_TOKEN); + const input = { path: "/repo", ref: "docs/a.md", operation: "preview", scope: ["docs/**"] }; + try { + expect(owner.client.initialized?.capabilities.workspacePluginFiles).toBe(true); + expect(await owner.client.request("workspace/pluginFiles", input)).toMatchObject({ content: "remote" }); + await expect(viewer.client.request("workspace/pluginFiles", input)).rejects.toMatchObject({ code: "FORBIDDEN" }); + for (const params of [{ ...input, operation: "write" }, { ...input, scope: undefined }, { ...input, hostKey: "other" }]) { + await expect(owner.client.request("workspace/pluginFiles", params)).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + } + expect(calls).toEqual([input]); + } finally { await owner.client.close(); await viewer.client.close(); } + const old = await harness(); const connection = await old.connect(OWNER_TOKEN); + try { + expect(connection.client.initialized?.capabilities.workspacePluginFiles).toBe(false); + await expect(connection.client.request("workspace/pluginFiles", input)).rejects.toMatchObject({ code: "CAPABILITY_UNAVAILABLE" }); + } finally { await connection.client.close(); } +}); + describe("RACP-WS handshake and authentication", () => { it("negotiates protocol, capabilities, limits, and the Host identity", async () => { const h = await harness(); diff --git a/packages/racp/src/server.ts b/packages/racp/src/server.ts index f9599f19db..ec517fca3d 100644 --- a/packages/racp/src/server.ts +++ b/packages/racp/src/server.ts @@ -180,6 +180,7 @@ export class RacpServer { terminal: Boolean(options.operations.terminal), workspaceTools: Boolean(options.operations.workspaceTools), workspaceFiles: Boolean(options.operations.workspaceFiles), + workspacePluginFiles: Boolean(options.operations.workspacePluginFiles), notifications: false, bindings: ["RACP-WS"], }; diff --git a/packages/shared/fixtures/racp.schema.json b/packages/shared/fixtures/racp.schema.json index e2bc3c58d3..4477860c2a 100644 --- a/packages/shared/fixtures/racp.schema.json +++ b/packages/shared/fixtures/racp.schema.json @@ -1,4 +1,176 @@ { + "WorkspacePluginFilesRequest": { + "type": "object", + "required": [ + "path", + "ref", + "operation", + "scope" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "ref": { + "type": "string", + "maxLength": 4096 + }, + "operation": { + "anyOf": [ + { + "type": "string", + "const": "list" + }, + { + "type": "string", + "const": "preview" + } + ] + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "WorkspacePluginFilesResult": { + "anyOf": [ + { + "type": "object", + "required": [ + "kind", + "entries" + ], + "properties": { + "kind": { + "type": "string", + "const": "list" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "path", + "isDirectory" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "isDirectory": { + "type": "boolean" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "mtimeMs": { + "type": "number" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "kind", + "content", + "size", + "mtimeMs" + ], + "properties": { + "kind": { + "type": "string", + "const": "text" + }, + "content": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "mtimeMs": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "kind", + "dataUrl", + "size", + "mtimeMs" + ], + "properties": { + "kind": { + "type": "string", + "const": "image" + }, + "dataUrl": { + "type": "string", + "pattern": "^data:image/[a-zA-Z0-9.+-]+;base64," + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "mtimeMs": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "kind", + "size", + "mtimeMs" + ], + "properties": { + "kind": { + "anyOf": [ + { + "type": "string", + "const": "binary" + }, + { + "type": "string", + "const": "tooLarge" + } + ] + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "mtimeMs": { + "type": "number" + } + }, + "additionalProperties": false + } + ] + }, "RequestContext": { "type": "object", "required": [ @@ -2050,6 +2222,9 @@ "workspaceFiles": { "type": "boolean" }, + "workspacePluginFiles": { + "type": "boolean" + }, "notifications": { "type": "boolean" }, diff --git a/packages/shared/src/racp.test.ts b/packages/shared/src/racp.test.ts index 343ec3f354..87f0581550 100644 --- a/packages/shared/src/racp.test.ts +++ b/packages/shared/src/racp.test.ts @@ -17,6 +17,8 @@ import { RacpInitializeResultSchema, RacpSessionSnapshotSchema, WorkspaceFilesRequestSchema, + WorkspacePluginFilesRequestSchema, + WorkspacePluginFilesResultSchema, allowedDecisionsAreCoherent, effectiveRemotePermissionMode, eventEnvelopeSequencingIsValid, @@ -375,3 +377,19 @@ describe("SSH repository instruction file contract", () => { expect(Value.Check(WorkspaceFilesRequestSchema, { path: "/repo", operation: "unknown" })).toBe(false); }); }); + +it("declares plugin file browsing as an owner-only read without changing workspace tools", () => { + expect(RACP_OPERATIONS["workspace/pluginFiles"]).toEqual({ role: "owner", profile: "remote-host", mutation: false }); + expect(RACP_OPERATIONS["workspace/files"]).toEqual({ role: "owner", profile: "remote-host", mutation: false }); +}); + +it("keeps the plugin file contract closed and requires real preview metadata", () => { + const input = { path: "/repo", ref: "docs/a.md", operation: "preview", scope: ["docs/**"] }; + expect(Value.Check(WorkspacePluginFilesRequestSchema, input)).toBe(true); + for (const invalid of [{ ...input, operation: "write" }, { ...input, scope: undefined }, { ...input, hostKey: "other" }]) { + expect(Value.Check(WorkspacePluginFilesRequestSchema, invalid)).toBe(false); + } + expect(Value.Check(WorkspacePluginFilesResultSchema, { kind: "text", content: "ok", size: 2, mtimeMs: 123 })).toBe(true); + expect(Value.Check(WorkspacePluginFilesResultSchema, { kind: "text", size: 2, mtimeMs: 123 })).toBe(false); + expect(Value.Check(WorkspacePluginFilesResultSchema, { kind: "image", dataUrl: "https://untrusted/image", size: 2, mtimeMs: 123 })).toBe(false); +}); diff --git a/packages/shared/src/racp.ts b/packages/shared/src/racp.ts index 516d390861..2731e3e40e 100644 --- a/packages/shared/src/racp.ts +++ b/packages/shared/src/racp.ts @@ -111,6 +111,31 @@ export const WorkspaceFilesRequestSchema = Type.Object({ }, { additionalProperties: false }); export type WorkspaceFilesRequest = Static; +/** Plugin reads carry the admitted read scope; no remote write operation exists. */ +export const WorkspacePluginFilesRequestSchema = Type.Object({ + path: Type.String({ minLength: 1, maxLength: 4096 }), + ref: Type.String({ maxLength: 4096 }), + operation: Type.Union([Type.Literal("list"), Type.Literal("preview")]), + scope: Type.Array(Type.String()), +}, { additionalProperties: false }); +export type WorkspacePluginFilesRequest = Static; + +const PluginFileEntrySchema = Type.Object({ + name: Type.String({ minLength: 1 }), + path: Type.String({ minLength: 1 }), + isDirectory: Type.Boolean(), + size: Type.Optional(Type.Integer({ minimum: 0 })), + mtimeMs: Type.Optional(Type.Number()), +}, { additionalProperties: false }); +const PluginPreviewMetadata = { size: Type.Integer({ minimum: 0 }), mtimeMs: Type.Number() }; +export const WorkspacePluginFilesResultSchema = Type.Union([ + Type.Object({ kind: Type.Literal("list"), entries: Type.Array(PluginFileEntrySchema) }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("text"), content: Type.String(), ...PluginPreviewMetadata }, { additionalProperties: false }), + Type.Object({ kind: Type.Literal("image"), dataUrl: Type.String({ pattern: "^data:image/[a-zA-Z0-9.+-]+;base64," }), ...PluginPreviewMetadata }, { additionalProperties: false }), + Type.Object({ kind: Type.Union([Type.Literal("binary"), Type.Literal("tooLarge")]), ...PluginPreviewMetadata }, { additionalProperties: false }), +]); +export type WorkspacePluginFilesResult = Static; + /** Narrow owner-only tool service; model configuration and credentials never cross it. */ export const WorkspaceToolExecuteSchema = Type.Object({ path: Type.String({ minLength: 1, maxLength: 4096 }), @@ -452,6 +477,7 @@ export const RacpServerCapabilitiesSchema = Type.Object({ terminal: Type.Boolean(), workspaceTools: Type.Optional(Type.Boolean()), workspaceFiles: Type.Optional(Type.Boolean()), + workspacePluginFiles: Type.Optional(Type.Boolean()), notifications: Type.Boolean(), bindings: Type.Array(Type.Union([Type.Literal("RACP-WS"), Type.Literal("RACP-HTTP"), Type.Literal("RACP-GRPC")])), }); @@ -593,6 +619,7 @@ export const RACP_OPERATIONS = { "workspace/toolAbort": { role: "owner", profile: "remote-host", mutation: true }, "workspace/toolShell": { role: "owner", profile: "remote-host", mutation: false }, "workspace/files": { role: "owner", profile: "remote-host", mutation: false }, + "workspace/pluginFiles": { role: "owner", profile: "remote-host", mutation: false }, "terminal/open": { role: "controller", profile: "remote-host", mutation: true }, "terminal/input": { role: "controller", profile: "remote-host", mutation: true }, "terminal/resize": { role: "controller", profile: "remote-host", mutation: true }, @@ -871,6 +898,8 @@ export function racpErrorIsRegistered(code: RacpErrorCode): boolean { // --------------------------------------------------------------------------- export const RACP_SCHEMAS = { + WorkspacePluginFilesRequest: WorkspacePluginFilesRequestSchema, + WorkspacePluginFilesResult: WorkspacePluginFilesResultSchema, RequestContext: RacpRequestContextSchema, Session: RacpSessionSchema, Turn: RacpTurnSchema, diff --git a/packages/shared/src/types/plugins.ts b/packages/shared/src/types/plugins.ts index e0702ccb7a..a7e8da91b7 100644 --- a/packages/shared/src/types/plugins.ts +++ b/packages/shared/src/types/plugins.ts @@ -408,6 +408,8 @@ export type PluginWorkspaceInfo = { projectId?: string; /** Every registered folder of that group, primary first. */ roots?: PluginWorkspaceRoot[]; + /** Display metadata only: `path` above remains the opaque workspace identity. */ + remote?: { path: string; readOnly: true }; }; /** One mirror an install tried, and what it answered. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d777c5d919..3e84a30f58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,9 @@ importers: '@pi-desktop/host-runtime': specifier: workspace:* version: link:../../packages/host-runtime + '@pi-desktop/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk '@pi-desktop/racp': specifier: workspace:* version: link:../../packages/racp diff --git a/scripts/e2e-remote-file-manager.mjs b/scripts/e2e-remote-file-manager.mjs new file mode 100644 index 0000000000..49819a37f3 --- /dev/null +++ b/scripts/e2e-remote-file-manager.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +/** Actual plugin/view + Desktop broker + loopback RACP + Host filesystem. No model or user profile. */ +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire, register } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pluginDir = process.argv[2] && resolve(process.argv[2]); +assert(pluginDir, "Usage: node scripts/e2e-remote-file-manager.mjs "); +const qa = createRequire(join(process.env.PI_FILE_MANAGER_QA_DIR || join(pluginDir, "tests/electron"), "package.json")); +const { _electron } = qa("playwright"); +const temp = await mkdtemp(join(tmpdir(), "pi-remote-manager-e2e-")); +process.env.PI_DESKTOP_DATA_DIR = join(temp, "desktop-data"); +register(new URL("../apps/desktop/test/helpers/ts-import-hooks.mjs", import.meta.url)); +const { PluginRuntime } = await import("../apps/desktop/electron/main/plugin-runtime.ts"); +const { createPluginWorkspaceReader } = await import("../apps/desktop/electron/main/remote/plugin-workspace-files.ts"); +const { createRacpRemoteHostClient } = await import("../apps/desktop/electron/main/remote/racp-remote-host-client.ts"); +const { pluginWorkspaceInfo, rememberProjectGroups } = await import("../apps/desktop/electron/main/workspace-roots.ts"); +const { createWorkspacePluginFiles } = await import("../apps/pi-host/src/plugin-workspace-files.ts"); +const { harness, OWNER_TOKEN } = await import("../packages/racp/dist/test-harness.js"); +const { bindRacpWebSocket, wsClientTransport } = await import("../packages/racp/dist/index.js"); + +let application, binding, adapter, bridge, runtime, protocol; +let held, entered, heldRef = "late.txt"; +const calls = [], invocations = [], errors = [], checks = []; +async function waitForSignal(promise, message) { + let timer; + try { + await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), 15_000); + })]); + } finally { clearTimeout(timer); } +} +try { + const remotePath = join(temp, "remote-repository"); + const localPath = join(temp, "local-control"); + await mkdir(join(remotePath, "docs"), { recursive: true }); + await mkdir(localPath); + await mkdir(join(remotePath, "nested")); + await mkdir(join(localPath, "nested")); + await writeFile(join(remotePath, "nested/deep.txt"), "REMOTE NESTED\n"); + await writeFile(join(localPath, "nested/deep.txt"), "WRONG LOCAL FILE\n"); + await writeFile(join(localPath, "page.html"), "

Local page

"); + await mkdir(join(temp, "host-data")); + await writeFile(join(remotePath, "docs/guide.md"), "REMOTE CONTENT\n"); + await writeFile(join(remotePath, "late.txt"), "LATE REMOTE CONTENT\n"); + await writeFile(join(remotePath, ".env"), "PRIVATE FIXTURE"); + await writeFile(join(remotePath, "pixel.png"), Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l8sAAAAASUVORK5CYII=", "base64")); + await writeFile(join(localPath, "local.txt"), "LOCAL CONTROL\n"); + const identity = "remote-project:e2e"; + let workspace = identity; + const groups = [{ id: "e2e", name: "SSH repository", primaryPath: identity, + roots: [{ path: identity, name: "SSH repository", position: 0 }], remote: { hostKey: "fixture", path: remotePath } }]; + rememberProjectGroups(groups); + const host = { async call(method) { + assert.equal(method, "project.groups.list", "only durable binding lookup is stubbed"); return { groups }; + } }; + protocol = await harness({ operations: { workspacePluginFiles: createWorkspacePluginFiles({ + browseRoot: temp, protectedPaths: [join(temp, "host-data")], + }) } }); + binding = await bindRacpWebSocket({ server: protocol.server, authenticator: protocol.authenticator, + port: 0, log: () => {} }); + const makeAdapter = () => createRacpRemoteHostClient({ clientInfo: { name: "remote-files-e2e", version: "fixture" }, + transport: wsClientTransport({ url: `ws://127.0.0.1:${binding.address.port}/v1/racp/ws`, token: OWNER_TOKEN }) }); + adapter = makeAdapter(); + await adapter.connect(); + const boot = { async workspacePluginFiles(_key, input) { + calls.push(input); + const result = await adapter.client.request("workspace/pluginFiles", input); + if (held && input.ref === heldRef) { + entered.resolve(); await held.promise; + } + return result; + } }; + runtime = new PluginRuntime({ + hostEntry: join(root, "apps/desktop/electron/main/plugin-host-process.mjs"), + spawnProcess({ entry }) { + const child = fork(entry, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] }); + return { postMessage: message => { if (child.connected) child.send(message); }, + onMessage: handler => child.on("message", handler), onExit: handler => child.on("exit", code => handler(code ?? 0)), + kill: () => child.kill() }; + }, + getWorkspacePath: () => workspace, + getWorkspaceInfo: () => pluginWorkspaceInfo(workspace), + getAppearance: () => ({ theme: "light", base: "light", locale: "en", pluginTheme: null }), + readRemoteWorkspaceFiles: createPluginWorkspaceReader({ getHost: () => host, getBoot: () => boot }), + }); + await runtime.loadFromPath(pluginDir); + // The small fixture bridge transports production panel calls without loading the user's Desktop. + bridge = createServer(async (request, response) => { + try { + assert.equal(request.method, "POST"); assert.equal(request.url, "/invoke"); + let body = ""; for await (const chunk of request) body += chunk; + const { channel, payload } = JSON.parse(body); + invocations.push({ workspace, channel, payload }); + const value = await runtime.invokePanelBridge("pi.file-manager", channel, payload); + response.setHeader("Content-Type", "application/json"); response.end(JSON.stringify({ value })); + } catch (error) { + response.statusCode = 500; response.end(JSON.stringify({ error: error.message })); + } + }); + await new Promise((done, reject) => { bridge.once("error", reject); bridge.listen(0, "127.0.0.1", done); }); + const port = bridge.address().port; + await writeFile(join(temp, "preload.cjs"), `const {contextBridge,ipcRenderer}=require('electron'); +contextBridge.exposeInMainWorld('pluginBridge',{invoke:(channel,payload)=>ipcRenderer.invoke('bridge',channel,payload), +on:(event,handler)=>{const listener=(_e,payload)=>handler(payload);ipcRenderer.on('fixture:'+event,listener);return()=>ipcRenderer.removeListener('fixture:'+event,listener)}});`); + await writeFile(join(temp, "main.cjs"), `const {app,BrowserWindow,ipcMain,session}=require('electron'); +app.setPath('userData',${JSON.stringify(join(temp, "profile"))}); +app.whenReady().then(async()=>{ + session.defaultSession.webRequest.onBeforeRequest((details,done)=>done({cancel:/^https?:/i.test(details.url)})); + ipcMain.handle('bridge',async(_event,channel,payload)=>{const response=await fetch('http://127.0.0.1:${port}/invoke',{method:'POST',body:JSON.stringify({channel,payload})});const body=await response.json();if(body.error)throw new Error(body.error);return body.value;}); + const window=new BrowserWindow({width:1100,height:800,webPreferences:{preload:${JSON.stringify(join(temp, "preload.cjs"))},sandbox:true,contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}}); + await window.loadFile(${JSON.stringify(join(pluginDir, "views/index.html"))}); +});app.on('window-all-closed',()=>app.quit());`); + const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; + const electronBinary = process.env.PI_ELECTRON_BINARY || resolveElectronBinary(root).electronBinary; + application = await _electron.launch({ executablePath: electronBinary, args: [join(temp, "main.cjs")], env }); + const page = await application.firstWindow(); page.setDefaultTimeout(15_000); + page.on("pageerror", error => errors.push(String(error))); + await page.getByText("Remote · read-only: directory browsing and text/image previews", { exact: true }).waitFor(); + assert.equal(await page.getByRole("textbox", { name: "Search files" }).isDisabled(), true); + await page.locator('[role="treeitem"][data-path="docs"]').click(); + await page.locator('[role="treeitem"][data-path="docs/guide.md"]').click(); + await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent.includes("REMOTE CONTENT")); + assert.equal(await page.locator(".cm-content").getAttribute("contenteditable"), "false"); + assert.equal(await page.getByRole("button", { name: "Save", exact: true }).isDisabled(), true); + await page.locator(".cm-content").click(); await page.keyboard.type("MUST NOT EDIT"); + await page.keyboard.press(process.platform === "darwin" ? "Meta+s" : "Control+s"); + assert.equal(await readFile(join(remotePath, "docs/guide.md"), "utf8"), "REMOTE CONTENT\n"); + assert.equal((await page.locator(".cm-content").textContent()).includes("MUST NOT EDIT"), false); + await page.locator('[data-path="docs/guide.md"]').click({ button: "right" }); + assert.deepEqual(await page.getByRole("menuitem").allTextContents(), ["Copy relative path", "Copy absolute path", "Refresh"]); + await page.keyboard.press("Escape"); + assert.equal(await page.locator('[data-path=".env"]').count(), 0); + checks.push("remote nested navigation, real text, credentials hidden, editor/save/menu read-only"); + await page.locator('[data-path="pixel.png"]').click(); + await page.waitForFunction(() => [...document.images].some(image => image.complete && image.naturalWidth === 1)); + checks.push("real remote image bytes decoded in Chromium"); + + await adapter.close(); + await page.getByRole("banner").getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("tree").getByText(/disconnected/i).waitFor(); + adapter = makeAdapter(); await adapter.connect(); + await page.getByRole("banner").getByRole("button", { name: "Refresh", exact: true }).click(); + await page.locator('[data-path="late.txt"]').waitFor(); + checks.push("disconnect is visible and refresh recovers without local substitution"); + held = Promise.withResolvers(); entered = Promise.withResolvers(); + await page.locator('[data-path="late.txt"]').click(); + await waitForSignal(entered.promise, "Remote preview never started"); + workspace = localPath; + await page.locator('[data-path="local.txt"]').waitFor(); + held.resolve(); held = undefined; + await page.locator('[data-path="local.txt"]').click(); + await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent.includes("LOCAL CONTROL")); + assert.equal(await page.locator(".cm-content").getAttribute("contenteditable"), "true"); + assert.equal((await page.locator("body").textContent()).includes("LATE REMOTE CONTENT"), false); + checks.push("project switch rejects late remote contents and restores local editing"); + await page.locator('[data-path="page.html"]').click({ button: "right" }); + await page.getByRole("menuitem", { name: /^Open with/ }).hover(); + await page.getByRole("menuitem", { name: /browser/i }).waitFor(); + await page.keyboard.press("Escape"); + assert(invocations.some(call => call.workspace === localPath && call.channel === "fm.editors"), "local editor detection must resume after a remote startup"); + checks.push("local Browser action and editor detection recover after remote startup"); + + workspace = identity; + await page.getByText("Remote · read-only: directory browsing and text/image previews", { exact: true }).waitFor(); + await page.locator('[data-path="nested"]').waitFor(); + held = Promise.withResolvers(); entered = Promise.withResolvers(); heldRef = "nested"; + await application.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.send("fixture:view:open", { path: "nested/deep.txt" })); + await waitForSignal(entered.promise, "Ancestor listing never started"); + workspace = localPath; + await page.getByText("Remote · read-only: directory browsing and text/image previews", { exact: true }).waitFor({ state: "hidden" }); + const released = held; held = undefined; released.resolve(); + await page.evaluate(() => window.pluginBridge.invoke("workspace.get")); + await page.getByRole("banner").getByRole("button", { name: /Show file list/ }).click(); + await page.locator('[data-path="local.txt"]').click(); + await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent.includes("LOCAL CONTROL")); + assert(!invocations.some(call => call.workspace === localPath && call.channel === "fm.read" && call.payload?.path === "nested/deep.txt"), "stale ancestor chain must not dispatch a read in the new workspace"); + checks.push("host-open ancestor loading stops when the workspace changes"); + assert(calls.some(call => call.operation === "list" && call.ref === "docs")); + assert(calls.some(call => call.operation === "preview" && call.ref === "pixel.png")); + assert(calls.every(call => call.path === remotePath && JSON.stringify(call.scope) === '["**"]')); + assert.deepEqual(errors, []); + if (process.env.PI_E2E_ARTIFACTS) { + await mkdir(process.env.PI_E2E_ARTIFACTS, { recursive: true }); + await page.screenshot({ path: join(process.env.PI_E2E_ARTIFACTS, "remote-files-local-control.png") }); + } + console.log("REMOTE_FILE_MANAGER_E2E " + JSON.stringify({ ok: true, checks, remoteRequests: calls.length })); +} catch (error) { + if (application && process.env.PI_E2E_ARTIFACTS) { + await mkdir(process.env.PI_E2E_ARTIFACTS, { recursive: true }); + await (await application.firstWindow()).screenshot({ path: join(process.env.PI_E2E_ARTIFACTS, "remote-files-failure.png") }); + } + throw error; +} finally { + held?.resolve(); + await application?.close(); + if (runtime) await runtime.unload("pi.file-manager"); + if (bridge) await new Promise(done => bridge.close(done)); + await adapter?.close(); protocol?.server.close(); await binding?.close(); + await rm(temp, { recursive: true, force: true }); +} From 6f513074f55efc6ca856927be6e11d9e2790974b Mon Sep 17 00:00:00 2001 From: vastsa Date: Thu, 22 Oct 2026 21:58:13 +0800 Subject: [PATCH 2/7] fix(plugin-sdk): bound plugin directory listings Keep the remote Host file service within the same per-directory result limit as local plugin reads. Share the limit through the SDK so the two implementations cannot drift, and cover the boundary with a large-directory regression. --- apps/desktop/electron/main/plugin-runtime.ts | 5 ++--- apps/pi-host/src/plugin-workspace-files.test.ts | 11 +++++++++++ apps/pi-host/src/plugin-workspace-files.ts | 3 ++- packages/plugin-sdk/src/fs-policy.ts | 2 ++ packages/plugin-sdk/src/index.ts | 1 + 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index e575685e65..655b882637 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -29,6 +29,7 @@ import { normalizeFsPath, parseNetDomains, parseSkillFrontmatter, + PLUGIN_FS_LIST_MAX_ENTRIES, pluginMcpToolKey, pluginSkillId, pluginThemeId, @@ -801,8 +802,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; /** @@ -6064,7 +6063,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; diff --git a/apps/pi-host/src/plugin-workspace-files.test.ts b/apps/pi-host/src/plugin-workspace-files.test.ts index 2dac8a88c7..929302c34b 100644 --- a/apps/pi-host/src/plugin-workspace-files.test.ts +++ b/apps/pi-host/src/plugin-workspace-files.test.ts @@ -55,6 +55,17 @@ it("enforces scope on both the requested and canonical file", { timeout: 60_000 expect(await read("list", "docs", [])).toMatchObject({ entries: [] }); }); +it("caps a remote directory listing at the plugin SDK limit", { timeout: 60_000 }, async () => { + const { root, read } = await fixture(); + await Promise.all(Array.from({ length: 1005 }, (_, index) => + writeFile(join(root, `item-${String(index).padStart(4, "0")}.txt`), ""), + )); + const result = await read("list", "", ["**"]); + expect(result).toMatchObject({ kind: "list" }); + if (result.kind !== "list") throw new Error("expected a directory listing"); + expect(result.entries).toHaveLength(1000); +}); + it("refuses credentials, escaped links, protected Host data and browse-root escapes", { timeout: 60_000 }, async () => { const { home, root, data, read } = await fixture(); await symlink(join(root, ".env"), join(root, "docs", "credential.txt")); diff --git a/apps/pi-host/src/plugin-workspace-files.ts b/apps/pi-host/src/plugin-workspace-files.ts index 925b6ce119..e5a7cded16 100644 --- a/apps/pi-host/src/plugin-workspace-files.ts +++ b/apps/pi-host/src/plugin-workspace-files.ts @@ -2,7 +2,7 @@ import { readdir, realpath, stat } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { RacpError } from "@pi-desktop/agent-host"; import { previewFile } from "@pi-desktop/host-runtime"; -import { isDeniedFsPath, isFsPathInScope } from "@pi-desktop/plugin-sdk"; +import { isDeniedFsPath, isFsPathInScope, PLUGIN_FS_LIST_MAX_ENTRIES } from "@pi-desktop/plugin-sdk"; import type { WorkspacePluginFilesRequest, WorkspacePluginFilesResult } from "@pi-desktop/shared"; import type { RacpHostOperations } from "@pi-desktop/racp"; @@ -85,6 +85,7 @@ export function createWorkspacePluginFiles(options: { } const entries: Extract["entries"] = []; for (const entry of (await readdir(target, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + if (entries.length >= PLUGIN_FS_LIST_MAX_ENTRIES) break; const childRef = ref ? `${ref}/${entry.name}` : entry.name; try { const child = await authorize(childRef, false); diff --git a/packages/plugin-sdk/src/fs-policy.ts b/packages/plugin-sdk/src/fs-policy.ts index 7fc573d990..392b8ad062 100644 --- a/packages/plugin-sdk/src/fs-policy.ts +++ b/packages/plugin-sdk/src/fs-policy.ts @@ -18,6 +18,8 @@ export type PluginFsRoot = "workspace" | "userSelected"; export type PluginFsMode = "read" | "write" | "delete"; export const PLUGIN_FS_MODES = ["read", "write", "delete"] as const; +/** Maximum entries returned by one `pi.fs.list` call. */ +export const PLUGIN_FS_LIST_MAX_ENTRIES = 1000; export type PluginFsRule = { root: PluginFsRoot; diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 3eac52a873..b250d0be0b 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -2561,6 +2561,7 @@ export { FS_DENY_DIR_SEGMENTS, FS_DENY_FILE_PATTERNS, LEGACY_FS_PERMISSIONS, + PLUGIN_FS_LIST_MAX_ENTRIES, PLUGIN_FS_MODES, type MatchFsGlobOptions, type PluginFsMode, From 41648cb289d80ea7169c07c4c36e108809d9613e Mon Sep 17 00:00:00 2001 From: Seraph-x Date: Thu, 22 Oct 2026 22:18:18 +0800 Subject: [PATCH 3/7] fix(plugins): validate the bundled remote file manager delivery path Default remote-file acceptance to the shipped plugin and record the loaded directory, so a passing development-plugin run cannot stand in for a completed bundled update. Keep the paired release gate explicit in both delivery specifications. Repair the two CI test assumptions exposed by the updated integration candidate: permit attributes on the main pane and supply the WebSocket close reason at the transport fixture boundary. --- apps/desktop/test/auto-update.test.mjs | 2 +- apps/desktop/test/helpers/live-adapter-work.mjs | 2 +- docs/spec/06-delivery/04-e2e-test-plan.md | 12 +++++++++--- docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md | 8 ++++++-- scripts/e2e-remote-file-manager.mjs | 6 +++--- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/desktop/test/auto-update.test.mjs b/apps/desktop/test/auto-update.test.mjs index 5462c2add4..1ed3bcd7aa 100644 --- a/apps/desktop/test/auto-update.test.mjs +++ b/apps/desktop/test/auto-update.test.mjs @@ -209,7 +209,7 @@ test("renderer exposes the updates API, banner and settings row", () => { assert.match(releaseNotesDialogSource, /event\.key === "Escape"/); assert.match( appSource, - /
[\s\S]*?/, + /]*\bclassName="main-pane"[^>]*>[\s\S]*?/, "chat update notice is anchored inside the main pane", ); assert.match( diff --git a/apps/desktop/test/helpers/live-adapter-work.mjs b/apps/desktop/test/helpers/live-adapter-work.mjs index f4b2fbcf01..435d9eeeb9 100644 --- a/apps/desktop/test/helpers/live-adapter-work.mjs +++ b/apps/desktop/test/helpers/live-adapter-work.mjs @@ -28,7 +28,7 @@ export class FixtureLiveSocket extends EventEmitter { } receive(value) { this.emit("message", typeof value === "string" ? value : JSON.stringify(value)); } - close() { this.readyState = 3; this.emit("close", 1000); } + close(code = 1000, reason = "") { this.readyState = 3; this.emit("close", code, Buffer.from(reason)); } terminate() { this.terminated = true; this.close(); } settleReceipt(error) { this.receiptCallbacks.shift()?.(error); } diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index f6b392012b..bc26b46b8e 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -18522,9 +18522,15 @@ host-created files. The full app's file-preview viewer is covered separately. ### E2E-REMOTE-FILE-MANAGER: Scoped remote plugin reads -Run `node scripts/e2e-remote-file-manager.mjs `. -The directory must contain the companion upstream plugin fix and its rebuilt -`views/` (or the unpacked official PluginPack artifact). +Run `node scripts/e2e-remote-file-manager.mjs` to validate the shipped copy in +`apps/desktop/resources/plugins/pi.file-manager`. This is the release acceptance +path: passing against a separate development plugin does not prove the bundled +user path is repaired. The runner records the loaded directory in its output. +An optional `` argument selects the companion +plugin's rebuilt `views/` or unpacked official PluginPack artifact for development +validation only. Before accepting #1711, update the bundled release, its upstream +tag/commit and per-file checksums, then rerun the default path on the updated PR +integration candidate. `PI_FILE_MANAGER_QA_DIR` may point to an existing plugin `tests/electron` installation providing Playwright; `PI_ELECTRON_BINARY` selects an existing compatible Electron runtime. `PI_E2E_ARTIFACTS` optionally preserves screenshots. diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 60ea6b0bb2..586c73d622 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -9909,8 +9909,12 @@ host-created files. The full app's file-preview viewer is covered separately. ### E2E-REMOTE-FILE-MANAGER:受限的插件远程读取 -运行 `node scripts/e2e-remote-file-manager.mjs `。 -目录需包含配套插件修复和重新构建的 `views/`,也可使用官方 PluginPack 产物的解包目录。 +运行 `node scripts/e2e-remote-file-manager.mjs`,验证实际内置的 +`apps/desktop/resources/plugins/pi.file-manager`。这是发布验收路径:单独的开发插件 +通过不代表内置插件已修复。脚本会输出实际加载的目录。 +可选参数 `` 可选择配套插件重新构建的 `views/` 或官方 +PluginPack 产物的解包目录,仅用于开发验证。验收 #1711 前需更新内置正式版本、 +上游标签与提交及各文件校验值,再对更新后的 PR 集成候选运行默认路径。 `PI_FILE_MANAGER_QA_DIR` 可指向已有、提供 Playwright 的插件 `tests/electron` 环境; `PI_ELECTRON_BINARY` 选择兼容的已有 Electron;`PI_E2E_ARTIFACTS` 可保留截图。 diff --git a/scripts/e2e-remote-file-manager.mjs b/scripts/e2e-remote-file-manager.mjs index 49819a37f3..679f94625e 100644 --- a/scripts/e2e-remote-file-manager.mjs +++ b/scripts/e2e-remote-file-manager.mjs @@ -11,8 +11,8 @@ import { fileURLToPath } from "node:url"; import { resolveElectronBinary } from "./e2e/boot.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const pluginDir = process.argv[2] && resolve(process.argv[2]); -assert(pluginDir, "Usage: node scripts/e2e-remote-file-manager.mjs "); +const pluginDir = resolve(process.argv[2] || join(root, "apps/desktop/resources/plugins/pi.file-manager")); +console.log("REMOTE_FILE_MANAGER_PLUGIN " + JSON.stringify({ pluginDir })); const qa = createRequire(join(process.env.PI_FILE_MANAGER_QA_DIR || join(pluginDir, "tests/electron"), "package.json")); const { _electron } = qa("playwright"); const temp = await mkdtemp(join(tmpdir(), "pi-remote-manager-e2e-")); @@ -191,7 +191,7 @@ app.whenReady().then(async()=>{ await mkdir(process.env.PI_E2E_ARTIFACTS, { recursive: true }); await page.screenshot({ path: join(process.env.PI_E2E_ARTIFACTS, "remote-files-local-control.png") }); } - console.log("REMOTE_FILE_MANAGER_E2E " + JSON.stringify({ ok: true, checks, remoteRequests: calls.length })); + console.log("REMOTE_FILE_MANAGER_E2E " + JSON.stringify({ ok: true, pluginDir, checks, remoteRequests: calls.length })); } catch (error) { if (application && process.env.PI_E2E_ARTIFACTS) { await mkdir(process.env.PI_E2E_ARTIFACTS, { recursive: true }); From 632ff34431f77966ff94dd7b4c64210b8772528e Mon Sep 17 00:00:00 2001 From: Seraph-x Date: Fri, 23 Oct 2026 01:34:47 +0800 Subject: [PATCH 4/7] fix(ci): surface asynchronous certificate fixture assertions Assertions in HTTPS and proxy event callbacks escape the scenario catch. Electron then opens its default error dialog, hiding the assertion and blocking the probe until the outer process deadline kills it. Report uncaught fixture errors and exit with failure so Linux CI exposes the actual assertion. A real Electron fault-injection comparison reproduces the blocked dialog before this change and visible exit code 1 afterward. --- scripts/e2e/provider-client-certificate.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/e2e/provider-client-certificate.ts b/scripts/e2e/provider-client-certificate.ts index 1e1cc6fc3f..360a3a2396 100644 --- a/scripts/e2e/provider-client-certificate.ts +++ b/scripts/e2e/provider-client-certificate.ts @@ -11,6 +11,13 @@ import { app, net, session } from "electron"; import { applyNetworkProxy, disposeSystemProxyRelay } from "../../apps/desktop/electron/main/network-proxy"; import { probeModelList, probeProviderEndpoint } from "../../apps/desktop/electron/main/model-discovery"; +// Assertions in server callbacks run outside the scenario's try/catch. +// Report them instead of blocking CI in Electron's default error dialog. +process.on("uncaughtException", (error) => { + console.error(error); + app.exit(1); +}); + app.setPath("userData", process.argv[2]); app.commandLine.appendSwitch("ozone-platform", "headless"); app.disableHardwareAcceleration(); From 0243c1b07ffbafd576560a2b2435985ae59fb8f5 Mon Sep 17 00:00:00 2001 From: Seraph-x Date: Fri, 23 Oct 2026 01:47:34 +0800 Subject: [PATCH 5/7] fix(ci): align background cancellation checks with current Host logic The tool-abort repair moved detached Bash exclusion into the process cancellation branch. The older source assertion still expected the previous outer conditional and failed despite preserving that exclusion. Assert the current local-background None branch and registration path. Include exact viewport measurements when the unchanged long-history reader-position assertion fails, so Linux failures are diagnosable. --- apps/desktop/test/background-tasks.test.mjs | 2 +- scripts/e2e/transcript-long-history.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/test/background-tasks.test.mjs b/apps/desktop/test/background-tasks.test.mjs index 9ed9f75fd7..e47d40392b 100644 --- a/apps/desktop/test/background-tasks.test.mjs +++ b/apps/desktop/test/background-tasks.test.mjs @@ -78,7 +78,7 @@ test("a background Bash call returns a backgroundTaskId and never a blocking std test("turn cancellation and timeout cannot reach a background task: it never registers a bash cancellation", () => { assert.match( rpcSources.replace(/\r/g, ""), - /cancellation_receiver = if remote_workspace\.is_some\(\)[\s\S]*?&& !p\.run_in_background\)/, + /if remote_workspace\.is_none\(\) && p\.tool_name == "Bash" && p\.run_in_background \{\s*None\s*\} else \{[\s\S]*?register_bash_cancellation\(/, ); assert.match(rpcSources.replace(/\r/g, ""), /"Bash" \| "GenerateImages"/); }); diff --git a/scripts/e2e/transcript-long-history.tsx b/scripts/e2e/transcript-long-history.tsx index 6523802eb7..16a260556a 100644 --- a/scripts/e2e/transcript-long-history.tsx +++ b/scripts/e2e/transcript-long-history.tsx @@ -276,8 +276,15 @@ export async function transcriptLongHistoryProbe() { scroller.dispatchEvent(new Event("scroll", { bubbles: true })); await settled(); const readingTop = scroller.scrollTop; + const readingHeight = scroller.scrollHeight; await stream(largest, " reader-stays"); - assert(Math.abs(scroller.scrollTop - readingTop) < 2, "stream stole the reader's scroll position"); + assert(Math.abs(scroller.scrollTop - readingTop) < 2, + `stream stole the reader's scroll position: ${JSON.stringify({ + before: { top: readingTop, height: readingHeight }, + after: { top: scroller.scrollTop, height: scroller.scrollHeight }, + viewport: scroller.clientHeight, + reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches, + })}`); // Terminal re-key must replace exactly the provisional row, survive cache // reselection and expose fresh copy text through the real turn toolbar. From 6ba8d6b073f50d5c4548a280a60e04daec7351ac Mon Sep 17 00:00:00 2001 From: Seraph-x Date: Tue, 27 Oct 2026 19:36:52 +0800 Subject: [PATCH 6/7] fix(plugins): allow registered roots beyond browse boundary --- apps/pi-host/src/host-operations.ts | 5 +++ .../src/plugin-workspace-files.test.ts | 43 ++++++++++++++++++- apps/pi-host/src/plugin-workspace-files.ts | 4 +- docs/adr/plugin-remote-workspace-reads.md | 4 +- .../19-remote-agent-control-protocol.md | 5 ++- docs/spec/06-delivery/04-e2e-test-plan.md | 14 +++--- docs/spec/07-plugins/03-plugin-api.md | 8 ++-- .../19-remote-agent-control-protocol.md | 3 +- .../spec/06-delivery/04-e2e-test-plan.md | 3 +- docs/zh-CN/spec/07-plugins/03-plugin-api.md | 4 +- scripts/e2e-remote-file-manager.mjs | 5 ++- 11 files changed, 80 insertions(+), 18 deletions(-) diff --git a/apps/pi-host/src/host-operations.ts b/apps/pi-host/src/host-operations.ts index 170cdbca17..45400a6a04 100644 --- a/apps/pi-host/src/host-operations.ts +++ b/apps/pi-host/src/host-operations.ts @@ -262,6 +262,11 @@ export function createHostOperations(deps: HostOperationsDeps): Omit { + const host = requireHost(deps.getHost); + const result = await host.call<{ projects: ProjectRow[] }>("projects.list", {}).catch(hostError); + return (result.projects ?? []).some((project) => project.path === root); + }, }), ...(deps.revokeDevice ? { revokeDevice: deps.revokeDevice } : {}), }; diff --git a/apps/pi-host/src/plugin-workspace-files.test.ts b/apps/pi-host/src/plugin-workspace-files.test.ts index 929302c34b..2b8b47446c 100644 --- a/apps/pi-host/src/plugin-workspace-files.test.ts +++ b/apps/pi-host/src/plugin-workspace-files.test.ts @@ -9,6 +9,17 @@ afterEach(async () => { await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true }))); }); +function projectHost(paths: readonly string[]) { + return { + async call(method: string): Promise { + if (method === "projects.list") { + return { projects: paths.map((path, index) => ({ id: index + 1, path, name: `project-${index + 1}` })) } as T; + } + throw new Error(`unexpected ${method}`); + }, + }; +} + async function fixture() { const home = await realpath(await mkdtemp(join(tmpdir(), "pi-plugin-remote-"))); temporary.push(home); @@ -21,7 +32,7 @@ async function fixture() { await writeFile(join(root, ".env"), "private fixture"); await writeFile(join(data, "tokens.json"), "private host fixture"); const operations = createHostOperations({ - getHost: () => null, + getHost: () => projectHost([]), runtime: { compact: async () => ({ accepted: true }), isBusy: () => false }, browseRoot: home, dataDir: data, @@ -85,6 +96,36 @@ it("refuses credentials, escaped links, protected Host data and browse-root esca expect(home).not.toBe(outside); }); +it("allows an explicitly registered workspace root outside the browse root", { timeout: 60_000 }, async () => { + const scratch = await realpath(await mkdtemp(join(tmpdir(), "pi-plugin-registered-"))); + temporary.push(scratch); + const browseRoot = join(scratch, "home"); + const root = join(scratch, "workspace", "repo"); + const unregistered = join(scratch, "workspace", "other"); + await mkdir(browseRoot); + await mkdir(root, { recursive: true }); + await mkdir(unregistered); + await writeFile(join(root, "README.md"), "registered workspace\n"); + const operations = createHostOperations({ + getHost: () => projectHost([root]), + runtime: { compact: async () => ({ accepted: true }), isBusy: () => false }, + browseRoot, + dataDir: join(scratch, "host-data"), + }); + const read = (path: string) => operations.workspacePluginFiles!.read({ + path, + ref: "", + operation: "list", + scope: ["**"], + }); + + expect(await read(root)).toMatchObject({ + kind: "list", + entries: [{ name: "README.md", path: "README.md", isDirectory: false }], + }); + await expect(read(unregistered)).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); +}); + it("returns explicit oversized/binary results and missing-file errors", { timeout: 60_000 }, async () => { const { root, read } = await fixture(); await writeFile(join(root, "large.txt"), "x".repeat(512 * 1024 + 1)); diff --git a/apps/pi-host/src/plugin-workspace-files.ts b/apps/pi-host/src/plugin-workspace-files.ts index e5a7cded16..e71084aa7d 100644 --- a/apps/pi-host/src/plugin-workspace-files.ts +++ b/apps/pi-host/src/plugin-workspace-files.ts @@ -37,6 +37,7 @@ async function canonical(path: string): Promise { export function createWorkspacePluginFiles(options: { browseRoot: string; protectedPaths: readonly string[]; + isRegisteredRoot?: (path: string) => Promise; }): NonNullable { return { async read(input: WorkspacePluginFilesRequest): Promise { if (!isAbsolute(input.path) || /[\u0000-\u001f]/.test(input.path) @@ -53,7 +54,8 @@ export function createWorkspacePluginFiles(options: { } })); const protectedPath = (path: string) => protectedRoots.some((guarded) => inside(guarded, path)); - if (!inside(browseRoot, root) || protectedPath(root) || isDeniedFsPath(root)) throw forbidden(); + if (protectedPath(root) || isDeniedFsPath(root)) throw forbidden(); + if (!inside(browseRoot, root) && !(await options.isRegisteredRoot?.(root))) throw forbidden(); if (!(await stat(root)).isDirectory()) throw new RacpError("INVALID_ARGUMENT", "workspace is not a directory"); const authorize = async (requested: string, file: boolean) => { diff --git a/docs/adr/plugin-remote-workspace-reads.md b/docs/adr/plugin-remote-workspace-reads.md index b25f79373d..0106976fdf 100644 --- a/docs/adr/plugin-remote-workspace-reads.md +++ b/docs/adr/plugin-remote-workspace-reads.md @@ -21,7 +21,9 @@ resolves the remote binding from host-owned project records, and sends a closed `workspace/pluginFiles` request. The remote Host advertises this optional capability and accepts it only from paired owners. It validates canonical containment, protected Host data, the credential deny-list and the declared -scope before reading. Requested and canonical file names must both match scope. +scope before reading. A workspace root must be under the folder picker's browse +root or exactly match a canonical project root registered on the Host. Requested +and canonical file names must both match scope. The SDK accepts an optional expected workspace identity. All calls, including legacy calls without this argument, capture their root before asynchronous diff --git a/docs/spec/03-runtime/19-remote-agent-control-protocol.md b/docs/spec/03-runtime/19-remote-agent-control-protocol.md index a58141ae02..e77898cb89 100644 --- a/docs/spec/03-runtime/19-remote-agent-control-protocol.md +++ b/docs/spec/03-runtime/19-remote-agent-control-protocol.md @@ -643,8 +643,9 @@ Legacy remote-Agent operations above remain unchanged. by optional capability `workspacePluginFiles`. It accepts only `{ path, operation: "list" | "preview", ref, scope: string[] }`; no Host key, write payload or external absolute `ref` is accepted. The Host checks its browse -root, protected data, canonical containment, credential denials, and requested -and canonical scope before delivery. Results are a closed `kind` union: `list` +root or an exact canonical project root already registered on that Host, +protected data, canonical containment, credential denials, and requested and +canonical scope before delivery. Results are a closed `kind` union: `list` with root-relative entries, or `text`/`image`/`binary`/`tooLarge` with real size and `mtimeMs`. Text and images use the existing 512 KiB / 5 MiB preview caps. Desktop derives the remote root from its admitted project binding and rejects diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index ed84fc13a4..e6513f9985 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -18710,12 +18710,14 @@ compatible Electron runtime. `PI_E2E_ARTIFACTS` optionally preserves screenshots The isolated production plugin process and `file://` view use Desktop's actual SDK broker, durable-binding reader, loopback RACP transport and Host filesystem service. Only the fixture's project-record lookup and authentication bootstrap -are supplied by the harness. Verify nested listings, text/image bytes, hidden -credentials, read-only editor/save/menu controls, visible disconnect errors and -refresh recovery, rejected late previews and ancestor opens after a project -switch, and restored local editing/Browser/editor detection. No model, user -profile or external SSH server participates. Real SSH transport and native -Windows/Linux behavior require their separate platform acceptance. +are supplied by the harness. The remote fixture root is registered explicitly +outside the Host folder picker's browse root. Verify nested listings, +text/image bytes, hidden credentials, read-only editor/save/menu controls, +visible disconnect errors and refresh recovery, rejected late previews and +ancestor opens after a project switch, and restored local +editing/Browser/editor detection. No model, user profile or external SSH server +participates. Real SSH transport and native Windows/Linux behavior require +their separate platform acceptance. ### E2E-GENUI-inline-visualization-card: a visualization renders inline and stays sandboxed diff --git a/docs/spec/07-plugins/03-plugin-api.md b/docs/spec/07-plugins/03-plugin-api.md index 19a115cbc2..4dc7f337b0 100644 --- a/docs/spec/07-plugins/03-plugin-api.md +++ b/docs/spec/07-plugins/03-plugin-api.md @@ -391,9 +391,11 @@ identity and adds `remote: { path, readOnly: true }`; `remote.path` is a display path, never a local filesystem root. `fs.list` and `fs.readPreview` route these roots through the paired owner Host's optional `workspacePluginFiles` capability. The Host enforces canonical containment, protected paths, credential -denials and the plugin's declared scope. Other filesystem channels remain local -and reject opaque remote roots. A missing capability reports -`HOST_VERSION_MISMATCH`; no request falls back to the Desktop filesystem. +denials and the plugin's declared scope. The root must be below the Host's folder +picker browse root or exactly match a canonical project root registered on that +Host. Other filesystem channels remain local and reject opaque remote roots. A +missing capability reports `HOST_VERSION_MISMATCH`; no request falls back to the +Desktop filesystem. The optional `workspacePath` must equal the workspace identity captured by the view. A mismatch reports `WORKSPACE_CHANGED`. All reads capture their root diff --git a/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md b/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md index f3f61b27d8..018fa9645a 100644 --- a/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md +++ b/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md @@ -12,7 +12,8 @@ 插件远程只读文件使用独立的 owner-only `workspace/pluginFiles`,由可选能力 `workspacePluginFiles` 广播。封闭请求为 `{ path, operation: "list" | "preview", ref, scope: string[] }`,不接受 Host key、 -写入或根外绝对 ref。Host 校验浏览根、受保护数据、规范路径包含关系、凭据拒绝列表, +写入或根外绝对 ref。工作区根必须位于文件夹选择器的浏览根内,或精确匹配已在该 Host +注册的规范项目根;Host 继续校验受保护数据、规范路径包含关系、凭据拒绝列表, 以及原始/规范相对路径的 scope。结果为 `list` 条目或带真实 size/mtimeMs 的 `text`、`image`、`binary`、`tooLarge`;文本/图片上限为 512 KiB / 5 MiB。 Desktop 从已准入项目绑定选择远程根,绑定或连接生命周期改变后拒绝过期结果。 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index ef8fc7fcad..4cccbcbc2f 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -9960,7 +9960,8 @@ PluginPack 产物的解包目录,仅用于开发验证。验收 #1711 前需 隔离的真实插件进程与生产 `file://` 视图经过 Desktop SDK 网关、持久绑定读取器、 本机回环 RACP 传输和真实 Host 文件服务。仅项目记录查询和认证启动由夹具提供。 -检查嵌套目录、文本/图片、凭据隐藏、编辑器/保存/菜单只读、断线错误和刷新恢复, +远程夹具根会显式注册在 Host 文件夹选择器浏览根之外。检查嵌套目录、文本/图片、 +凭据隐藏、编辑器/保存/菜单只读、断线错误和刷新恢复, 以及切换项目后拒绝延迟预览和祖先打开请求,并恢复本地编辑、Browser 和编辑器探测。 不使用模型、用户 profile 或外部 SSH;真实 SSH 及 Windows/Linux 原生行为需另行平台验收。 diff --git a/docs/zh-CN/spec/07-plugins/03-plugin-api.md b/docs/zh-CN/spec/07-plugins/03-plugin-api.md index f82fceeb3a..90f68319b4 100644 --- a/docs/zh-CN/spec/07-plugins/03-plugin-api.md +++ b/docs/zh-CN/spec/07-plugins/03-plugin-api.md @@ -327,7 +327,9 @@ pi.fs.requestDirectory(): Promise<{ path: string; name: string } | null> SSH 项目的 `workspace.get` 保留不透明的 `remote-project:` 身份,并新增 `remote: { path, readOnly: true }`。`remote.path` 仅用于显示,不能交给本地文件系统。 `fs.list` 与 `fs.readPreview` 经配对 owner Host 的可选 `workspacePluginFiles` -能力读取;Host 检查规范路径包含关系、受保护目录、凭据拒绝列表与插件声明的 scope。 +能力读取;工作区根必须位于 Host 文件夹选择器的浏览根内,或精确匹配已在该 Host +注册的规范项目根;Host 继续检查规范路径包含关系、受保护目录、凭据拒绝列表与插件 +声明的 scope。 远程预览还返回真实 `mtimeMs`;其他文件通道仍限本地并明确拒绝远程身份。 缺少能力返回 `HOST_VERSION_MISMATCH`,不会改读 Desktop 文件系统。 diff --git a/scripts/e2e-remote-file-manager.mjs b/scripts/e2e-remote-file-manager.mjs index 470d79ebe2..d471e6872d 100644 --- a/scripts/e2e-remote-file-manager.mjs +++ b/scripts/e2e-remote-file-manager.mjs @@ -47,6 +47,7 @@ try { const localPath = join(temp, "local-control"); await mkdir(join(remotePath, "docs"), { recursive: true }); await mkdir(localPath); + await mkdir(join(temp, "browse-root")); await mkdir(join(remotePath, "nested")); await mkdir(join(localPath, "nested")); await writeFile(join(remotePath, "nested/deep.txt"), "REMOTE NESTED\n"); @@ -67,7 +68,9 @@ try { assert.equal(method, "project.groups.list", "only durable binding lookup is stubbed"); return { groups }; } }; protocol = await harness({ operations: { workspacePluginFiles: createWorkspacePluginFiles({ - browseRoot: temp, protectedPaths: [join(temp, "host-data")], + browseRoot: join(temp, "browse-root"), + protectedPaths: [join(temp, "host-data")], + isRegisteredRoot: async path => path === remotePath, }) } }); binding = await bindRacpWebSocket({ server: protocol.server, authenticator: protocol.authenticator, port: 0, log: () => {} }); From 830de6bd916ba040e027a70e4b15bb6521673ec3 Mon Sep 17 00:00:00 2001 From: Seraph-x Date: Tue, 27 Oct 2026 19:45:30 +0800 Subject: [PATCH 7/7] test(plugins): cover registered remote root delivery --- apps/pi-host/src/plugin-workspace-files.test.ts | 15 ++++++++++++--- scripts/e2e-remote-file-manager.mjs | 7 ++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/pi-host/src/plugin-workspace-files.test.ts b/apps/pi-host/src/plugin-workspace-files.test.ts index 2b8b47446c..cb48ced497 100644 --- a/apps/pi-host/src/plugin-workspace-files.test.ts +++ b/apps/pi-host/src/plugin-workspace-files.test.ts @@ -10,10 +10,16 @@ afterEach(async () => { }); function projectHost(paths: readonly string[]) { + const projects = paths.map((path, index) => ({ id: index + 1, path, name: `project-${index + 1}` })); return { - async call(method: string): Promise { + async call(method: string, params: Record = {}): Promise { if (method === "projects.list") { - return { projects: paths.map((path, index) => ({ id: index + 1, path, name: `project-${index + 1}` })) } as T; + return { projects } as T; + } + if (method === "projects.create") { + const project = { id: projects.length + 1, path: String(params.path), name: `project-${projects.length + 1}` }; + projects.push(project); + return { project } as T; } throw new Error(`unexpected ${method}`); }, @@ -106,8 +112,9 @@ it("allows an explicitly registered workspace root outside the browse root", { t await mkdir(root, { recursive: true }); await mkdir(unregistered); await writeFile(join(root, "README.md"), "registered workspace\n"); + const host = projectHost([]); const operations = createHostOperations({ - getHost: () => projectHost([root]), + getHost: () => host, runtime: { compact: async () => ({ accepted: true }), isBusy: () => false }, browseRoot, dataDir: join(scratch, "host-data"), @@ -119,6 +126,8 @@ it("allows an explicitly registered workspace root outside the browse root", { t scope: ["**"], }); + await expect(read(root)).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + await operations.projects.register(root); expect(await read(root)).toMatchObject({ kind: "list", entries: [{ name: "README.md", path: "README.md", isDirectory: false }], diff --git a/scripts/e2e-remote-file-manager.mjs b/scripts/e2e-remote-file-manager.mjs index d471e6872d..05f254f6b9 100644 --- a/scripts/e2e-remote-file-manager.mjs +++ b/scripts/e2e-remote-file-manager.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { fork } from "node:child_process"; import { createServer } from "node:http"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { createRequire, register } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -43,9 +43,10 @@ async function capture(page, name) { await page.screenshot({ path: join(process.env.PI_E2E_ARTIFACTS, name) }); } try { - const remotePath = join(temp, "remote-repository"); + const remoteDirectory = join(temp, "remote-repository"); const localPath = join(temp, "local-control"); - await mkdir(join(remotePath, "docs"), { recursive: true }); + await mkdir(join(remoteDirectory, "docs"), { recursive: true }); + const remotePath = await realpath(remoteDirectory); await mkdir(localPath); await mkdir(join(temp, "browse-root")); await mkdir(join(remotePath, "nested"));