diff --git a/.changeset/path-access-allowed-roots.md b/.changeset/path-access-allowed-roots.md new file mode 100644 index 0000000..ae2fd8f --- /dev/null +++ b/.changeset/path-access-allowed-roots.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Allow configured external filesystem roots to be listed, read, configured from the global settings UI, and completed from absolute `@` path suggestions while keeping absolute paths denied by default, advertise workspace-scoped file suggestion support as a remote-machine capability, and use `fzf` when available to improve file/path completion filtering. diff --git a/AGENTS.md b/AGENTS.md index ad93e79..c503ccf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,11 @@ When working on this project, assume the session runtime owner is long-lived and If you make changes that affect `src/server/sessiond.ts`, session runtime ownership, the session daemon protocol, or any code path only loaded by the session daemon, inform the user that a manual restart of the session daemon is needed. Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path. + +## Configuration conventions + +- `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API. +- Global user/machine config lives at `$PI_WEB_CONFIG` or `~/.config/pi-web/config.json`. +- Project-local PI WEB core config should use one commit-able file: `/.pi-web/config.json`. +- Core features should add keys to these config files, not create one project file per feature. +- Plugins may own separate project config files, such as `.pi-web/tasks.json`. diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 90b865a..6fffd22 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; -import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; +import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; const workspace: Workspace = { id: "w/1", @@ -84,6 +84,26 @@ describe("session API compatibility", () => { }); }); +describe("machine-scoped file suggestion API", () => { + it("uses the workspace-scoped route when the caller has enabled workspace-scoped suggestions", async () => { + const fetchMock = stubJsonFetch([]); + + await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked"); + }); + + it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => { + const fetchMock = stubJsonFetch([]); + + await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo"); + }); +}); + describe("machine-scoped terminal command-run API", () => { it("deletes workspaces through the selected machine scope", async () => { const fetchMock = stubJsonFetch(commandRun); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 628cdd5..c98227f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -209,14 +209,21 @@ export interface FileSuggestionQueryOptions { mode?: "file" | "path" | undefined; scope?: "tracked" | "all" | undefined; machineId?: string | undefined; + projectId?: string | undefined; + workspaceId?: string | undefined; + workspaceScoped?: boolean | undefined; } export const filesApi = { files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => { - const params = new URLSearchParams({ cwd, q: query }); + const params = new URLSearchParams({ q: query }); if (options.kind !== undefined) params.set("kind", options.kind); if (options.mode !== undefined) params.set("mode", options.mode); if (options.scope !== undefined) params.set("scope", options.scope); + if (options.workspaceScoped === true && options.projectId !== undefined && options.workspaceId !== undefined) { + return request(`${machinePrefix(options.machineId)}/projects/${encodeURIComponent(options.projectId)}/workspaces/${encodeURIComponent(options.workspaceId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion)); + } + params.set("cwd", cwd); return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion)); }, }; diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 059212e..3173515 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -37,7 +37,7 @@ describe("federated route contract", () => { ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)), ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)), ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)), - ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })), + ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })), ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 7e8beff..375fe77 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -7,14 +7,14 @@ describe("API parsers", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 53923cb..299daef 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -445,6 +445,8 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])), ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("plugins", optionalPlugins(record["plugins"])), + ...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])), + ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), }; @@ -453,10 +455,33 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined { if (value === undefined) return undefined; if (value === true) return true; - if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value; + if (isStringArray(value)) return value; throw new Error("Invalid PI WEB allowedHosts field"); } +function optionalPathAccess(value: unknown): PiWebConfigValues["pathAccess"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error("Invalid PI WEB pathAccess field"); + const allowedPaths = value["allowedPaths"]; + return { + ...optionalField("allowedPaths", optionalStringArray(allowedPaths, "pathAccess.allowedPaths")), + }; +} + +function optionalStringArray(value: unknown, field: string): string[] | undefined { + if (value === undefined) return undefined; + if (isNonEmptyStringArray(value)) return value; + throw new Error(`Invalid PI WEB ${field} field`); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== ""); +} + function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined { if (value === undefined) return undefined; if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field"); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 43dfa04..47014c3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1004,6 +1004,14 @@ export class PiWebApp extends LitElement { return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); } + private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { + if (machineId === "local") return true; + // COMPAT-CAP workspace.fileSuggestions: remote machines without this + // capability stay on the legacy cwd-based /files route. + const runtime = this.state.machineRuntimes[machineId]; + return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.workspaceFileSuggestions); + } + private archivedDeleteUnavailableMessage(): string { const machineName = this.state.selectedMachine?.name ?? "this machine"; return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; @@ -1738,7 +1746,7 @@ export class PiWebApp extends LitElement {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 27c3221..449c045 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -33,6 +33,9 @@ export class PromptEditor extends LitElement { @property() sessionId?: string; @property() cwd?: string; @property() machineId = "local"; + @property() projectId?: string; + @property() workspaceId?: string; + @property({ type: Boolean }) workspaceScopedFileSuggestions = false; @property({ type: Boolean }) canSteer = false; @property({ type: Boolean }) isCompacting = false; @property({ type: Boolean }) canStop = false; @@ -293,7 +296,7 @@ export class PromptEditor extends LitElement { ...(command.description === undefined ? {} : { description: command.description }), })); } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { - const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions); + const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) diff --git a/src/client/src/components/settings/SettingsGeneralPanel.ts b/src/client/src/components/settings/SettingsGeneralPanel.ts index b551b26..be2b10d 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.ts @@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement { Enter one host per line, or choose “Allow every host” to write true. + + ${this.renderEffectiveConfig()}