Archived
feat: add external path access allowlist
This commit is contained in:
@@ -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.
|
||||
@@ -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: `<project>/.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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
@@ -102,6 +110,7 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
@@ -173,6 +182,11 @@ function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string |
|
||||
return html`<span class="muted">Unset</span>`;
|
||||
}
|
||||
|
||||
function formatAllowedPaths(value: string[] | undefined): string | TemplateResult {
|
||||
if (value === undefined || value.length === 0) return html`<span class="muted">External paths denied</span>`;
|
||||
return value.join(", ");
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
}
|
||||
|
||||
@@ -3,36 +3,61 @@ import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
|
||||
|
||||
describe("settings config drafts", () => {
|
||||
it("converts PI WEB config values to editable general settings drafts", () => {
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: "8504",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local\n192.168.1.20",
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
});
|
||||
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||
});
|
||||
|
||||
it("converts drafts back to config while preserving shortcut and plugin preferences", () => {
|
||||
it("converts drafts back to config while preserving non-general preferences", () => {
|
||||
expect(configFromDraft({
|
||||
host: " 127.0.0.1 ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes global path access when the allowed paths field is cleared", () => {
|
||||
expect(configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
|
||||
});
|
||||
|
||||
it("rejects relative external paths before saving", () => {
|
||||
expect(() => configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "relative/path",
|
||||
})).toThrow("Allowed external paths must be absolute paths or start with ~");
|
||||
});
|
||||
|
||||
it("preserves the spawnSessions flag when saving general settings", () => {
|
||||
const result = configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { spawnSessions: true });
|
||||
expect(result.spawnSessions).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ export interface ConfigDraft {
|
||||
port: string;
|
||||
allowedHostsMode: "list" | "all";
|
||||
allowedHostsText: string;
|
||||
allowedPathsText: string;
|
||||
}
|
||||
|
||||
export function emptyConfigDraft(): ConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
@@ -17,6 +18,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
port: config.port === undefined ? "" : String(config.port),
|
||||
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
|
||||
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
const config: PiWebConfigValues = {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
};
|
||||
@@ -36,9 +39,22 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
config.port = parsed;
|
||||
}
|
||||
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseAllowedHostsText(value: string): string[] {
|
||||
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
|
||||
function parseAllowedPathsText(value: string): string[] {
|
||||
const paths = value.split("\n").map((path) => path.trim()).filter((path) => path !== "");
|
||||
const invalid = paths.find((path) => !isAbsoluteishAllowedPath(path));
|
||||
if (invalid !== undefined) throw new Error(`Allowed external paths must be absolute paths or start with ~: ${invalid}`);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isAbsoluteishAllowedPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
|
||||
}
|
||||
|
||||
+11
-5
@@ -18,18 +18,18 @@ afterEach(async () => {
|
||||
|
||||
describe("PI WEB config persistence", () => {
|
||||
it("writes and reads the configured PI WEB config path", () => {
|
||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions());
|
||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, testOptions());
|
||||
|
||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } });
|
||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } });
|
||||
expect(loadPiWebConfig(testOptions())).toEqual(saved);
|
||||
});
|
||||
|
||||
it("preserves unrelated config keys while replacing managed keys", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, future: { enabled: true } }, null, 2)}\n`, "utf8");
|
||||
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, future: { enabled: true } }, null, 2)}\n`, "utf8");
|
||||
|
||||
savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions());
|
||||
savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }, testOptions());
|
||||
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] });
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } });
|
||||
});
|
||||
|
||||
it("rejects invalid plugin config", async () => {
|
||||
@@ -38,6 +38,12 @@ describe("PI WEB config persistence", () => {
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans");
|
||||
});
|
||||
|
||||
it("rejects invalid path access config", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ pathAccess: { allowedPaths: [""] } }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
});
|
||||
|
||||
it("persists and reads maxUploadBytes", () => {
|
||||
savePiWebConfig({ maxUploadBytes: 1234 }, testOptions());
|
||||
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
||||
|
||||
@@ -101,6 +101,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
||||
delete existing["allowedHosts"];
|
||||
delete existing["shortcuts"];
|
||||
delete existing["plugins"];
|
||||
delete existing["pathAccess"];
|
||||
delete existing["maxUploadBytes"];
|
||||
delete existing["spawnSessions"];
|
||||
delete existing["subsessions"];
|
||||
@@ -124,6 +125,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
|
||||
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
@@ -137,6 +139,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
||||
...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}),
|
||||
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
|
||||
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
||||
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
|
||||
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
|
||||
@@ -209,6 +212,19 @@ function parseAllowedHostsEnv(value: string): string[] | true {
|
||||
return value.split(",").map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
|
||||
export function parsePathAccessConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["pathAccess"]> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config pathAccess must be an object: ${path}`);
|
||||
const allowedPaths = value["allowedPaths"];
|
||||
return {
|
||||
...(allowedPaths !== undefined ? { allowedPaths: parseAllowedPaths(allowedPaths, path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedPaths(value: unknown, path: string): string[] {
|
||||
if (!isNonEmptyStringArray(value)) throw new Error(`PI WEB config pathAccess.allowedPaths must be an array of non-empty strings: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
|
||||
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
|
||||
|
||||
+83
-1
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
@@ -15,6 +15,7 @@ import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -22,12 +23,14 @@ let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||
let piWebConfig: PiWebConfigValues;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
sessionDaemonRequests = [];
|
||||
piWebConfig = {};
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
workspaces: new WorkspaceService(),
|
||||
@@ -48,6 +51,7 @@ beforeEach(async () => {
|
||||
}),
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
config: fakeConfigService(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||
@@ -478,6 +482,64 @@ describe("buildApp", () => {
|
||||
expect(tooLargeResponse.statusCode).toBe(400);
|
||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||
});
|
||||
|
||||
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Local Suggestions", path: projectDir, create: true },
|
||||
});
|
||||
expect(addResponse.statusCode).toBe(200);
|
||||
await writeFile(join(projectDir, "sdk.md"), "local sdk\n");
|
||||
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(projectDir)}&q=sdk&scope=all` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
|
||||
});
|
||||
|
||||
it("serves project-configured allowed external files through the workspace explorer", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "External", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const externalDir = join(tempDir, "external-docs");
|
||||
const deniedFile = join(tempDir, "secret.md");
|
||||
await mkdir(externalDir);
|
||||
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
|
||||
await writeFile(deniedFile, "secret\n");
|
||||
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
const fileResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
|
||||
const treeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
|
||||
const suggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
|
||||
const localSuggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
|
||||
const deniedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
|
||||
|
||||
expect(fileResponse.statusCode).toBe(200);
|
||||
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
|
||||
expect(treeResponse.statusCode).toBe(200);
|
||||
expect(treeResponse.json()).toMatchObject({
|
||||
path: externalDir,
|
||||
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
|
||||
truncated: false,
|
||||
});
|
||||
expect(suggestionResponse.statusCode).toBe(200);
|
||||
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
|
||||
expect(localSuggestionResponse.statusCode).toBe(200);
|
||||
expect(localSuggestionResponse.json()).toEqual([]);
|
||||
expect(deniedResponse.statusCode).toBe(400);
|
||||
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
|
||||
});
|
||||
});
|
||||
|
||||
interface CapturedSessionDaemonRequest {
|
||||
@@ -486,6 +548,26 @@ interface CapturedSessionDaemonRequest {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
function fakeConfigService() {
|
||||
return {
|
||||
read: () => piWebConfigResponse(piWebConfig),
|
||||
write: (config: PiWebConfigValues) => {
|
||||
piWebConfig = config;
|
||||
return piWebConfigResponse(config);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: join(tempDir, "config.json"),
|
||||
exists: false,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||
return {
|
||||
request: (method, path, body) => {
|
||||
|
||||
+18
-10
@@ -7,7 +7,8 @@ import fastifyWebsocket from "@fastify/websocket";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js";
|
||||
import { normalizeRequestCwd } from "./workingDirectory.js";
|
||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
@@ -16,7 +17,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
@@ -76,13 +77,19 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
|
||||
});
|
||||
}
|
||||
|
||||
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
|
||||
interface LocalFileSuggestionRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalFileSuggestionRouteOptions = {}): void {
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
const cwd = normalizeRequestCwd(request.query.cwd);
|
||||
if (request.query.mode === "path") return await listPathSuggestions(cwd, request.query.q ?? "");
|
||||
return await listFileSuggestions(cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
||||
const query = request.query.q ?? "";
|
||||
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForCwd(cwd, projects, workspaces, options.config) : undefined;
|
||||
if (request.query.mode === "path") return await listPathSuggestions(cwd, query, pathAccess);
|
||||
return await listFileSuggestions(cwd, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -96,6 +103,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
|
||||
@@ -118,7 +126,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerConfigRoutes(app, deps.config);
|
||||
registerConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
@@ -128,8 +136,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
|
||||
registerSessionProxyRoutes(app, sessionDaemon);
|
||||
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api", { config: configService });
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
|
||||
registerGitRoutes(app, projects, workspaces);
|
||||
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
|
||||
@@ -137,8 +145,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon);
|
||||
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
|
||||
|
||||
registerLocalFileSuggestionRoutes(app, "/api");
|
||||
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
||||
registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api", { config: configService });
|
||||
registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
|
||||
|
||||
registerMachineProxyRoutes(app, machines);
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("config routes", () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } },
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } });
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,30 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid path access payloads before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { pathAccess: { allowedPaths: [""] } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid max upload bytes before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { maxUploadBytes: 0 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
|
||||
@@ -58,6 +58,8 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
const allowedHosts = value["allowedHosts"];
|
||||
const shortcuts = value["shortcuts"];
|
||||
const plugins = value["plugins"];
|
||||
const pathAccess = value["pathAccess"];
|
||||
const maxUploadBytes = value["maxUploadBytes"];
|
||||
const spawnSessions = value["spawnSessions"];
|
||||
const subsessions = value["subsessions"];
|
||||
if (host !== undefined) {
|
||||
@@ -71,6 +73,8 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
|
||||
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
|
||||
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
|
||||
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
|
||||
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
|
||||
if (spawnSessions !== undefined) {
|
||||
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
|
||||
config.spawnSessions = spawnSessions;
|
||||
@@ -98,6 +102,30 @@ function parseShortcutsRequest(value: unknown): Record<string, string | null> {
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePathAccessRequest(value: unknown): NonNullable<PiWebConfig["pathAccess"]> {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config pathAccess must be an object");
|
||||
const allowedPaths = value["allowedPaths"];
|
||||
return {
|
||||
...(allowedPaths === undefined ? {} : { allowedPaths: parseAllowedPathsRequest(allowedPaths) }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedPathsRequest(value: unknown): string[] {
|
||||
if (!isNonEmptyStringArray(value)) {
|
||||
throw new Error("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
}
|
||||
|
||||
function parseMaxUploadBytesRequest(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error("PI WEB config maxUploadBytes must be a positive integer");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
|
||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
|
||||
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
|
||||
|
||||
@@ -4,13 +4,20 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
import type { PiWebConfigService } from "./configRoutes.js";
|
||||
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||
export interface WorkspaceExplorerRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await listWorkspaceTree(context.root, request.query.path);
|
||||
return await listWorkspaceTree(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -19,7 +26,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await readWorkspaceFile(context.root, request.query.path);
|
||||
return await readWorkspaceFile(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -28,7 +35,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
|
||||
return await reply
|
||||
.type(preview.mimeType)
|
||||
.header("Cache-Control", "private, max-age=3600")
|
||||
@@ -41,4 +48,16 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/files`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const query = request.query.q ?? "";
|
||||
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForWorkspaceContext(context, options.config) : undefined;
|
||||
if (request.query.mode === "path") return await listPathSuggestions(context.root, query, pathAccess);
|
||||
return await listFileSuggestions(context.root, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import type { PiWebConfigService } from "../configRoutes.js";
|
||||
import type { ProjectService } from "../projects/projectService.js";
|
||||
import type { WorkspaceContext } from "./workspaceContext.js";
|
||||
import type { WorkspaceService } from "./workspaceService.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { loadEffectiveProjectPathAccess } from "./projectPiWebConfig.js";
|
||||
|
||||
export async function pathAccessForWorkspaceContext(context: WorkspaceContext, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
|
||||
if (config === undefined) return undefined;
|
||||
const response = await config.read();
|
||||
return loadEffectiveProjectPathAccess(context.project.path, response.effectiveConfig);
|
||||
}
|
||||
|
||||
export async function pathAccessForCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
|
||||
if (config === undefined) return undefined;
|
||||
const response = await config.read();
|
||||
const projectPath = await projectPathForWorkspaceCwd(cwd, projects, workspaces);
|
||||
if (projectPath === undefined) return response.effectiveConfig.pathAccess;
|
||||
return loadEffectiveProjectPathAccess(projectPath, response.effectiveConfig);
|
||||
}
|
||||
|
||||
async function projectPathForWorkspaceCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService): Promise<string | undefined> {
|
||||
for (const project of await projects.list()) {
|
||||
if (cwdPathsEqual(project.path, cwd)) return project.path;
|
||||
if ((await workspaces.list(project)).some((workspace) => cwdPathsEqual(workspace.path, cwd))) return project.path;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -49,6 +49,23 @@ describe("readWorkspaceFile", () => {
|
||||
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("reads allowed absolute files outside the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const external = await tempWorkspace();
|
||||
await writeFile(join(external, "README.md"), "external docs\n");
|
||||
|
||||
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
|
||||
|
||||
expect(file).toMatchObject({
|
||||
path: join(external, "README.md"),
|
||||
language: "markdown",
|
||||
content: "external docs\n",
|
||||
truncated: false,
|
||||
binary: false,
|
||||
});
|
||||
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("detects binary files and omits binary content", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import type { FileContentResponse } from "../../shared/apiTypes.js";
|
||||
import type { FileContentResponse, PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { imageMimeTypeForPath } from "./imagePreviewService.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
export async function readWorkspaceFile(rootPath: string, path: string | undefined): Promise<FileContentResponse> {
|
||||
export async function readWorkspaceFile(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileContentResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const bytesToRead = Math.min(s.size, MAX_BYTES);
|
||||
const buffer = await readFilePrefix(target, bytesToRead);
|
||||
const media = mediaForPath(relativePath);
|
||||
const media = mediaForPath(displayPath);
|
||||
const binary = media.mediaType === "image" || isProbablyBinary(buffer);
|
||||
return {
|
||||
path: relativePath,
|
||||
...languageForPath(relativePath),
|
||||
path: displayPath,
|
||||
...languageForPath(displayPath),
|
||||
...media,
|
||||
encoding: "utf8",
|
||||
size: s.size,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
|
||||
import { listFileSuggestions, listPathSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
@@ -12,6 +12,26 @@ async function tempWorkspace(): Promise<string> {
|
||||
return root;
|
||||
}
|
||||
|
||||
function fzfRecords(input: string | Buffer | undefined): string[] {
|
||||
if (typeof input === "string") return input.split("\0").filter(Boolean);
|
||||
if (Buffer.isBuffer(input)) return input.toString("utf8").split("\0").filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
async function trySymlink(target: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await symlink(target, path, "dir");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -86,6 +106,67 @@ describe("file suggestions", () => {
|
||||
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
|
||||
});
|
||||
|
||||
it("uses fzf to filter and rank file suggestions after candidates are gathered", async () => {
|
||||
const fzfInputs: string[][] = [];
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/server/app.ts\0scripts/start.ts\0docs/reference.md\0" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
fzf: (file, args, options) => {
|
||||
expect(file).toBe("fzf");
|
||||
expect(args).toEqual(["--filter", "st", "--read0", "--print0"]);
|
||||
fzfInputs.push(fzfRecords(options.input));
|
||||
return Promise.resolve({ stdout: "scripts/start.ts\0src/server/app.ts\0" });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions("/repo", "st", { scope: "tracked" }, deps)).resolves.toEqual([
|
||||
{ path: "scripts/start.ts", kind: "tracked" },
|
||||
{ path: "src/server/app.ts", kind: "tracked" },
|
||||
]);
|
||||
expect(fzfInputs).toEqual([[
|
||||
"src/",
|
||||
"src/server/",
|
||||
"src/server/app.ts",
|
||||
"scripts/",
|
||||
"scripts/start.ts",
|
||||
"docs/",
|
||||
"docs/reference.md",
|
||||
]]);
|
||||
});
|
||||
|
||||
it("falls back to TypeScript file ranking when fzf fails", async () => {
|
||||
let fzfCalls = 0;
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "klingit-go/cli/cmd/dev/main.go\0MD PRojects here.md\0" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
fzf: () => {
|
||||
fzfCalls += 1;
|
||||
return Promise.reject(Object.assign(new Error("spawn fzf ENOENT"), { code: "ENOENT" }));
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = await listFileSuggestions("/repo", "MD", { scope: "tracked" }, deps);
|
||||
|
||||
expect(fzfCalls).toBe(1);
|
||||
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
|
||||
});
|
||||
|
||||
it("treats an fzf no-match exit as an empty filtered result", async () => {
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
fzf: () => Promise.reject(Object.assign(new Error("no match"), { exitCode: 1 })),
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions("/repo", "app", { scope: "tracked" }, deps)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves git filenames without trimming whitespace", async () => {
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
@@ -120,4 +201,132 @@ describe("file suggestions", () => {
|
||||
{ path: "src/app.ts", kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses allowed roots for absolute-ish file suggestion queries", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
await writeFile(join(external, "sdk.md"), "external sdk\n");
|
||||
|
||||
await expect(listFileSuggestions(workspace, join(external, "s"), { pathAccess: { allowedPaths: [external] } })).resolves.toEqual([
|
||||
{ path: join(external, "sdk.md"), kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips absolute-ish suggestions that would escape an allowed root through symlinks", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
const secret = join(root, "secret");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
await mkdir(secret);
|
||||
await writeFile(join(external, "sdk.md"), "external sdk\n");
|
||||
await writeFile(join(secret, "token.txt"), "secret\n");
|
||||
if (!await trySymlink(secret, join(external, "escape"))) return;
|
||||
|
||||
await expect(listPathSuggestions(workspace, `${external}/`, { allowedPaths: [external] })).resolves.toEqual([
|
||||
{ path: join(external, "sdk.md"), kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps tilde-prefixed allowed-root suggestions matchable by fzf", async () => {
|
||||
const workspace = await tempWorkspace();
|
||||
const homeEntry = await mkdtemp(join(homedir(), ".pi-web-files-"));
|
||||
temporaryRoots.push(homeEntry);
|
||||
const expectedPath = `~/${basename(homeEntry)}/`;
|
||||
const deps: FileSuggestionDependencies = {
|
||||
fzf: (file, args, options) => {
|
||||
expect(file).toBe("fzf");
|
||||
expect(args).toEqual(["--filter", "~/", "--read0", "--print0"]);
|
||||
expect(fzfRecords(options.input)).toContain(expectedPath);
|
||||
return Promise.resolve({ stdout: `${expectedPath}\0` });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions(workspace, "~/", { pathAccess: { allowedPaths: ["~/"] } }, deps)).resolves.toEqual([
|
||||
{ path: expectedPath, kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps normal file suggestions workspace-local even when allowed roots are configured", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
await writeFile(join(external, "sdk.md"), "external sdk\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions(workspace, "sdk", { scope: "all", pathAccess: { allowedPaths: [external] } }, deps)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps relative path suggestions workspace-local and skips symlink escapes", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const outside = join(root, "outside");
|
||||
await mkdir(workspace);
|
||||
await mkdir(outside);
|
||||
await writeFile(join(workspace, "local.md"), "local\n");
|
||||
await writeFile(join(outside, "outside.txt"), "outside\n");
|
||||
|
||||
await expect(listPathSuggestions(workspace, "../out")).resolves.toEqual([]);
|
||||
if (!await trySymlink(outside, join(workspace, "link"))) return;
|
||||
await expect(listPathSuggestions(workspace, "link/")).resolves.toEqual([]);
|
||||
await expect(listPathSuggestions(workspace, "")).resolves.toEqual([{ path: "local.md", kind: "other" }]);
|
||||
});
|
||||
|
||||
it("uses fzf to filter path suggestions after directory candidates are gathered", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "scripts"));
|
||||
await mkdir(join(root, "src"));
|
||||
await writeFile(join(root, "notes.md"), "notes\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
fzf: (file, args, options) => {
|
||||
expect(file).toBe("fzf");
|
||||
expect(args).toEqual(["--filter", "sc", "--read0", "--print0"]);
|
||||
expect(fzfRecords(options.input)).toEqual(["scripts/", "src/", "notes.md"]);
|
||||
return Promise.resolve({ stdout: "../secret\0scripts/\0" });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listPathSuggestions(root, "sc", undefined, deps)).resolves.toEqual([
|
||||
{ path: "scripts/", kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to path-prefix ordering when fzf fails", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "scripts"));
|
||||
await mkdir(join(root, "src"));
|
||||
await writeFile(join(root, "server.md"), "server\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
fzf: () => Promise.reject(Object.assign(new Error("fzf failed"), { exitCode: 2 })),
|
||||
};
|
||||
|
||||
await expect(listPathSuggestions(root, "s", undefined, deps)).resolves.toEqual([
|
||||
{ path: "scripts/", kind: "other" },
|
||||
{ path: "src/", kind: "other" },
|
||||
{ path: "server.md", kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suggests configured allowed roots without reading parent directories", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
|
||||
await expect(listPathSuggestions(workspace, external.slice(0, -4), { allowedPaths: [external] })).resolves.toEqual([
|
||||
{ path: `${external}/`, kind: "other" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative, sep, win32 } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import type { ClientFileSuggestion } from "../types.js";
|
||||
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, type PathAccessPolicy } from "./pathAccessPolicy.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandMaxBuffer = 1024 * 1024 * 8;
|
||||
const maxFilesystemFallbackPaths = 20_000;
|
||||
const maxFileSuggestions = 80;
|
||||
|
||||
interface ExecFileOptions {
|
||||
interface CommandRunnerOptions {
|
||||
cwd: string;
|
||||
maxBuffer: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string | Buffer;
|
||||
}
|
||||
|
||||
type CommandRunner = (file: string, args: string[], options: CommandRunnerOptions) => Promise<{ stdout: string }>;
|
||||
|
||||
class CommandExitError extends Error {
|
||||
readonly exitCode?: number;
|
||||
|
||||
constructor(file: string, code: number | null, stderr: string) {
|
||||
const codeText = code === null ? "unknown" : String(code);
|
||||
super(`${file} exited with code ${codeText}${stderr === "" ? "" : `: ${stderr}`}`);
|
||||
this.name = "CommandExitError";
|
||||
if (code !== null) this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type FileSuggestionScope = "tracked" | "all";
|
||||
@@ -21,56 +38,221 @@ export type FileSuggestionScope = "tracked" | "all";
|
||||
export interface FileSuggestionOptions {
|
||||
kind?: ClientFileSuggestion["kind"] | undefined;
|
||||
scope?: FileSuggestionScope | undefined;
|
||||
pathAccess?: PiWebPathAccessConfig | undefined;
|
||||
}
|
||||
|
||||
export interface FileSuggestionDependencies {
|
||||
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
|
||||
execFile?: CommandRunner;
|
||||
fzf?: CommandRunner;
|
||||
}
|
||||
|
||||
export function isAbsoluteishFileSuggestionQuery(query = ""): boolean {
|
||||
return isAbsoluteishPath(fileQueryText(query));
|
||||
}
|
||||
|
||||
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const queryText = fileQueryText(query);
|
||||
if (isAbsoluteishFileSuggestionQuery(query)) {
|
||||
return (await listPathSuggestions(cwd, queryText, options.pathAccess, deps))
|
||||
.filter((file) => options.kind === undefined || file.kind === options.kind)
|
||||
.slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeFileQuery(query);
|
||||
const exec = deps.execFile ?? execFileAsync;
|
||||
const files = await listFilesForScope(cwd, options.scope, exec);
|
||||
return rankFileSuggestions(
|
||||
const command = deps.execFile ?? runCommand;
|
||||
const files = await listFilesForScope(cwd, options.scope, command);
|
||||
return (await rankFileSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
files.filter((file) => options.kind === undefined || file.kind === options.kind),
|
||||
normalizedQuery,
|
||||
).slice(0, maxFileSuggestions);
|
||||
fzfRunnerForDependencies(deps),
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
|
||||
const normalizedPrefix = prefix.replace(/^@/, "").replace(/\\/g, "/");
|
||||
export async function listPathSuggestions(cwd: string, prefix = "", pathAccess?: PiWebPathAccessConfig, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const query = fileQueryText(prefix);
|
||||
const fzf = fzfRunnerForDependencies(deps);
|
||||
if (isAbsoluteishPath(query)) return listAllowedPathSuggestions(cwd, query, pathAccess, fzf);
|
||||
|
||||
const normalizedPrefix = query.replace(/\\/g, "/");
|
||||
const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`;
|
||||
const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix);
|
||||
const entries = await readdir(join(cwd, directoryPrefix), { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && entry.isSymbolicLink()) {
|
||||
try {
|
||||
isDirectory = (await stat(join(cwd, directoryPrefix, entry.name))).isDirectory();
|
||||
} catch {
|
||||
isDirectory = false;
|
||||
}
|
||||
}
|
||||
suggestions.push({ path: `${directoryPrefix}${entry.name}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions
|
||||
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
||||
.slice(0, 80);
|
||||
const candidates = await listDirectoryEntrySuggestions(cwd, directoryPrefix);
|
||||
return (await rankPathSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
candidates,
|
||||
searchPrefix,
|
||||
() => prefixPathSuggestions(candidates, searchPrefix),
|
||||
fzf,
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listDirectoryEntrySuggestions(cwd: string, directoryPrefix: string): Promise<ClientFileSuggestion[]> {
|
||||
const policy = await createPathAccessPolicy(cwd, undefined);
|
||||
const resolved = await resolveWorkspaceSuggestionDirectory(policy, directoryPrefix);
|
||||
if (resolved === undefined) return [];
|
||||
|
||||
const entries = await readdir(resolved.target, { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries.sort(compareDirectoryEntries)) {
|
||||
const childPath = appendRequestPath(resolved.displayPath, entry.name);
|
||||
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
|
||||
if (isDirectory === undefined) continue;
|
||||
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function resolveWorkspaceSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
|
||||
return resolved.kind === "workspace" ? resolved : undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllowedPathSuggestions(cwd: string, query: string, pathAccess: PiWebPathAccessConfig | undefined, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
const policy = await createPathAccessPolicy(cwd, pathAccess);
|
||||
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
|
||||
const rootCandidates = allowedRootSuggestionCandidates(policy, query);
|
||||
const directoryCandidates = await listAllowedDirectoryEntryCandidates(policy, query);
|
||||
return (await rankPathSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
mergeSuggestions(rootCandidates, directoryCandidates),
|
||||
query,
|
||||
() => mergeSuggestions(allowedRootPrefixSuggestions(policy, query), prefixPathSuggestions(directoryCandidates, pathSuggestionPrefix(query).searchPrefix)).sort(compareFileSuggestions),
|
||||
fzf,
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
function allowedRootPrefixSuggestions(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
|
||||
return allowedRootSuggestionCandidates(policy, query).filter((suggestion) => pathStartsWith(suggestion.path, query));
|
||||
}
|
||||
|
||||
function allowedRootSuggestionCandidates(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const root of policy.allowedRoots) {
|
||||
for (const displayPath of allowedRootDisplayPaths(root.path, query)) {
|
||||
const path = ensureTrailingPathSeparator(displayPath);
|
||||
if (hasTrailingPathSeparator(query) && stripTrailingPathSeparators(path) === stripTrailingPathSeparators(query)) continue;
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
suggestions.push({ path, kind: "other" });
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function allowedRootDisplayPaths(rootPath: string, query: string): string[] {
|
||||
if (query !== "~" && !query.startsWith("~/") && !query.startsWith("~\\")) return [rootPath];
|
||||
|
||||
const home = homedir();
|
||||
const homeRelativePath = relative(home, rootPath);
|
||||
if (!isInsideRelativePath(homeRelativePath)) return [rootPath];
|
||||
const separator = query.startsWith("~\\") ? "\\" : "/";
|
||||
const tildePath = homeRelativePath === "" ? "~" : `~${separator}${homeRelativePath.split(/[\\/]+/u).join(separator)}`;
|
||||
return [tildePath, rootPath];
|
||||
}
|
||||
|
||||
async function listAllowedDirectoryEntryCandidates(policy: PathAccessPolicy, query: string): Promise<ClientFileSuggestion[]> {
|
||||
const { directoryPrefix } = pathSuggestionPrefix(query);
|
||||
const resolved = await resolveSuggestionDirectory(policy, directoryPrefix);
|
||||
if (resolved === undefined) return [];
|
||||
|
||||
const entries = await readdir(resolved.target, { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries.sort(compareDirectoryEntries)) {
|
||||
const childPath = appendRequestPath(directoryPrefix, entry.name);
|
||||
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
|
||||
if (isDirectory === undefined) continue;
|
||||
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function resolveSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
|
||||
return resolved.kind === "allowed" ? resolved : undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function suggestionEntryIsDirectory(policy: PathAccessPolicy, childPath: string, entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): Promise<boolean | undefined> {
|
||||
if (!entry.isSymbolicLink()) return entry.isDirectory();
|
||||
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, childPath);
|
||||
const result = await stat(resolved.target);
|
||||
if (result.isDirectory()) return true;
|
||||
if (result.isFile()) return false;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function pathSuggestionPrefix(query: string): { directoryPrefix: string; searchPrefix: string } {
|
||||
if (query === "~" || hasTrailingPathSeparator(query)) return { directoryPrefix: query, searchPrefix: "" };
|
||||
const directory = dirname(query);
|
||||
return { directoryPrefix: directory === "." ? "" : directory, searchPrefix: basename(query) };
|
||||
}
|
||||
|
||||
function appendRequestPath(base: string, name: string): string {
|
||||
if (base === "") return name;
|
||||
if (hasTrailingPathSeparator(base)) return `${base}${name}`;
|
||||
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
|
||||
return `${base}/${name}`;
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, query: string): boolean {
|
||||
return path.toLowerCase().startsWith(query.toLowerCase());
|
||||
}
|
||||
|
||||
function ensureTrailingPathSeparator(path: string): string {
|
||||
return hasTrailingPathSeparator(path) ? path : `${path}/`;
|
||||
}
|
||||
|
||||
function hasTrailingPathSeparator(path: string): boolean {
|
||||
return path.endsWith("/") || path.endsWith("\\");
|
||||
}
|
||||
|
||||
function stripTrailingPathSeparators(path: string): string {
|
||||
let end = path.length;
|
||||
while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
||||
return path.slice(0, end);
|
||||
}
|
||||
|
||||
function isInsideRelativePath(path: string): boolean {
|
||||
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
||||
}
|
||||
|
||||
function isPathSuggestionMiss(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message === "Path is outside allowed paths"
|
||||
|| error.message === "Path does not exist"
|
||||
|| error.message === "Path traversal is not allowed"
|
||||
|| error.message === "Path escapes workspace"
|
||||
|| error.message.startsWith("Path is not absolute:");
|
||||
}
|
||||
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
if (scope === "all") return listAllFiles(cwd, exec);
|
||||
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
|
||||
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
|
||||
}
|
||||
|
||||
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked");
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
git(cwd, ["ls-files", "-z"], exec),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
|
||||
@@ -81,7 +263,7 @@ async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
|
||||
];
|
||||
}
|
||||
|
||||
async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listAllFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [gitFiles, plainFiles] = await Promise.all([
|
||||
listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []),
|
||||
listPlainFiles(cwd, exec, true),
|
||||
@@ -89,7 +271,7 @@ async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
|
||||
return mergeSuggestions(gitFiles, plainFiles);
|
||||
}
|
||||
|
||||
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
async function listPlainFiles(cwd: string, exec: CommandRunner, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
try {
|
||||
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"];
|
||||
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
|
||||
@@ -138,13 +320,75 @@ async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink:
|
||||
}
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
|
||||
async function git(cwd: string, args: string[], exec: CommandRunner): Promise<string> {
|
||||
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function normalizeFileQuery(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "").toLowerCase();
|
||||
return fileQueryText(query).toLowerCase();
|
||||
}
|
||||
|
||||
function fileQueryText(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "");
|
||||
}
|
||||
|
||||
function fzfRunnerForDependencies(deps: FileSuggestionDependencies): CommandRunner | undefined {
|
||||
return deps.fzf ?? (deps.execFile === undefined ? runCommand : undefined);
|
||||
}
|
||||
|
||||
async function rankFileSuggestionsWithOptionalFzf(cwd: string, files: ClientFileSuggestion[], normalizedQuery: string, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
return rankSuggestionsWithOptionalFzf(cwd, files, normalizedQuery, () => rankFileSuggestions(files, normalizedQuery), fzf);
|
||||
}
|
||||
|
||||
async function rankPathSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
return rankSuggestionsWithOptionalFzf(cwd, candidates, query, fallback, fzf);
|
||||
}
|
||||
|
||||
async function rankSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
if (fzf === undefined || query === "" || candidates.length === 0) return fallback();
|
||||
|
||||
try {
|
||||
return await fzfFilterSuggestions(cwd, candidates, query, fzf);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function fzfFilterSuggestions(cwd: string, candidates: ClientFileSuggestion[], query: string, fzf: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const byPath = new Map(candidates.map((suggestion) => [suggestion.path, suggestion]));
|
||||
const { stdout } = await runFzf(cwd, [...byPath.keys()], query, fzf);
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const path of nulRecords(stdout)) {
|
||||
const suggestion = byPath.get(path);
|
||||
if (suggestion === undefined || seen.has(suggestion.path)) continue;
|
||||
seen.add(suggestion.path);
|
||||
suggestions.push(suggestion);
|
||||
}
|
||||
if (suggestions.length === 0 && stdout !== "") throw new Error("fzf returned paths outside the gathered suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function runFzf(cwd: string, candidates: string[], query: string, fzf: CommandRunner): Promise<{ stdout: string }> {
|
||||
try {
|
||||
return await fzf("fzf", ["--filter", query, "--read0", "--print0"], { cwd, maxBuffer: commandMaxBuffer, input: `${candidates.join("\0")}\0` });
|
||||
} catch (error) {
|
||||
if (errorExitCode(error) === 1) return { stdout: "" };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function prefixPathSuggestions(candidates: ClientFileSuggestion[], searchPrefix: string): ClientFileSuggestion[] {
|
||||
const normalizedSearchPrefix = searchPrefix.toLowerCase();
|
||||
return candidates
|
||||
.filter((suggestion) => pathSuggestionName(suggestion.path).toLowerCase().startsWith(normalizedSearchPrefix))
|
||||
.sort(compareFileSuggestions);
|
||||
}
|
||||
|
||||
function pathSuggestionName(path: string): string {
|
||||
const stripped = stripTrailingPathSeparators(path);
|
||||
return stripped.split(/[\\/]+/u).filter(Boolean).at(-1) ?? stripped;
|
||||
}
|
||||
|
||||
function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] {
|
||||
@@ -197,6 +441,10 @@ function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion
|
||||
return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function compareDirectoryEntries(a: { isDirectory(): boolean; name: string }, b: { isDirectory(): boolean; name: string }): number {
|
||||
return Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function kindRank(kind: ClientFileSuggestion["kind"]): number {
|
||||
switch (kind) {
|
||||
case "tracked": return 0;
|
||||
@@ -209,6 +457,72 @@ function pathDepth(path: string): number {
|
||||
return path.split("/").filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function runCommand(file: string, args: string[], options: CommandRunnerOptions): Promise<{ stdout: string }> {
|
||||
const { input, ...execOptions } = options;
|
||||
if (input === undefined) return execFileAsync(file, args, execOptions);
|
||||
return runCommandWithInput(file, args, { ...execOptions, input });
|
||||
}
|
||||
|
||||
async function runCommandWithInput(file: string, args: string[], options: CommandRunnerOptions & { input: string | Buffer }): Promise<{ stdout: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(file, args, {
|
||||
cwd: options.cwd,
|
||||
...(options.env === undefined ? {} : { env: options.env }),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let settled = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes > options.maxBuffer) {
|
||||
child.kill();
|
||||
rejectOnce(new Error(`${file} stdout exceeded maxBuffer`));
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBytes += chunk.length;
|
||||
if (stderrBytes > options.maxBuffer) {
|
||||
child.kill();
|
||||
rejectOnce(new Error(`${file} stderr exceeded maxBuffer`));
|
||||
return;
|
||||
}
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", rejectOnce);
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) {
|
||||
resolve({ stdout });
|
||||
return;
|
||||
}
|
||||
reject(new CommandExitError(file, code, stderr));
|
||||
});
|
||||
child.stdin.on("error", () => undefined);
|
||||
child.stdin.end(options.input);
|
||||
});
|
||||
}
|
||||
|
||||
function errorExitCode(error: unknown): number | undefined {
|
||||
if (error instanceof CommandExitError) return error.exitCode;
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
if ("exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
||||
if ("code" in error && typeof error.code === "number") return error.code;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function textLines(text: string): string[] {
|
||||
return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== "");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,22 @@ describe("listWorkspaceTree", () => {
|
||||
expect(tree.entries[0]).toMatchObject({ name: "main.ts", path: "src/client/main.ts", type: "file" });
|
||||
});
|
||||
|
||||
it("lists allowed absolute directories outside the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const external = await tempWorkspace();
|
||||
await mkdir(join(external, "docs"));
|
||||
await writeFile(join(external, "sdk.ts"), "export {};\n");
|
||||
|
||||
const tree = await listWorkspaceTree(root, external, { allowedPaths: [external] });
|
||||
|
||||
expect(tree.path).toBe(external);
|
||||
expect(tree.entries.map((entry) => [entry.name, entry.path, entry.type])).toEqual([
|
||||
["docs", join(external, "docs"), "directory"],
|
||||
["sdk.ts", join(external, "sdk.ts"), "file"],
|
||||
]);
|
||||
await expect(listWorkspaceTree(root, external)).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("rejects non-directory targets and unsafe paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "file.txt"), "content");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FileTreeEntry, FileTreeResponse } from "../../shared/apiTypes.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
import { isAbsolute, join, win32 } from "node:path";
|
||||
import type { FileTreeEntry, FileTreeResponse, PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const MAX_ENTRIES = 1000;
|
||||
|
||||
export async function listWorkspaceTree(rootPath: string, path: string | undefined): Promise<FileTreeResponse> {
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
export async function listWorkspaceTree(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileTreeResponse> {
|
||||
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
|
||||
const stat = await lstat(target);
|
||||
if (!stat.isDirectory()) throw new Error("Path is not a directory");
|
||||
|
||||
@@ -18,11 +18,18 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
|
||||
const selected = sorted.slice(0, MAX_ENTRIES);
|
||||
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
|
||||
const absolute = join(target, entry.name);
|
||||
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
|
||||
const childPath = appendRequestPath(displayPath, entry.name);
|
||||
const childStat = await lstat(absolute);
|
||||
const type: FileTreeEntry["type"] = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
|
||||
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
|
||||
return { name: entry.name, path: childPath, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
|
||||
}));
|
||||
|
||||
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
|
||||
return { path: displayPath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
|
||||
}
|
||||
|
||||
function appendRequestPath(base: string, name: string): string {
|
||||
if (base === "") return name;
|
||||
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
|
||||
if (base.endsWith("/") || base.endsWith("\\")) return `${base}${name}`;
|
||||
return `${base}/${name}`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createReadStream, type ReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string | undefined> = {
|
||||
".avif": "image/avif",
|
||||
@@ -28,16 +29,16 @@ export function imageMimeTypeForPath(path: string): string | undefined {
|
||||
return IMAGE_MIME_TYPES[extname(path).toLowerCase()];
|
||||
}
|
||||
|
||||
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined): Promise<WorkspaceImagePreview> {
|
||||
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<WorkspaceImagePreview> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const mimeType = imageMimeTypeForPath(relativePath);
|
||||
const mimeType = imageMimeTypeForPath(displayPath);
|
||||
if (mimeType === undefined) throw new Error("Image preview is not supported for this file type");
|
||||
if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`);
|
||||
return {
|
||||
path: relativePath,
|
||||
path: displayPath,
|
||||
mimeType,
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function tempRoot(prefix = "pi-web-path-access-"): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("path access policy", () => {
|
||||
it("keeps relative requests workspace-local and identifies absolute-ish paths", async () => {
|
||||
const workspace = await tempRoot();
|
||||
await mkdir(join(workspace, "src"));
|
||||
await writeFile(join(workspace, "src", "main.ts"), "export {};\n");
|
||||
const policy = await createPathAccessPolicy(workspace, undefined);
|
||||
|
||||
await expect(resolvePathAccessTarget(policy, "./src//main.ts")).resolves.toMatchObject({
|
||||
kind: "workspace",
|
||||
root: await realpath(workspace),
|
||||
target: await realpath(join(workspace, "src", "main.ts")),
|
||||
displayPath: "src/main.ts",
|
||||
});
|
||||
|
||||
expect(isAbsoluteishPath("src/main.ts")).toBe(false);
|
||||
expect(isAbsoluteishPath("/tmp/file.txt")).toBe(true);
|
||||
expect(isAbsoluteishPath("~/SDKs/readme.md")).toBe(true);
|
||||
expect(isAbsoluteishPath("C:\\Users\\dev\\file.txt")).toBe(true);
|
||||
expect(isAbsoluteishPath("\\\\server\\share\\file.txt")).toBe(true);
|
||||
await expect(resolvePathAccessTarget(policy, join(workspace, "src", "main.ts"))).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("expands and canonicalizes allowed roots before resolving absolute targets", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const home = join(root, "home");
|
||||
const sdk = join(home, "SDKs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(sdk, { recursive: true });
|
||||
await writeFile(join(sdk, "readme.md"), "sdk docs\n");
|
||||
|
||||
const policy = await createPathAccessPolicy(workspace, { allowedPaths: ["~/SDKs"] }, { homeDir: home });
|
||||
|
||||
expect(policy.allowedRoots).toEqual([{ source: "~/SDKs", path: sdk, realPath: await realpath(sdk) }]);
|
||||
await expect(resolvePathAccessTarget(policy, "~/SDKs/readme.md", { homeDir: home })).resolves.toMatchObject({
|
||||
kind: "allowed",
|
||||
root: await realpath(sdk),
|
||||
target: await realpath(join(sdk, "readme.md")),
|
||||
displayPath: join(sdk, "readme.md"),
|
||||
});
|
||||
});
|
||||
|
||||
it("validates configured roots as existing directories", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const fileRoot = join(root, "not-a-directory.txt");
|
||||
await mkdir(workspace);
|
||||
await writeFile(fileRoot, "not a directory");
|
||||
|
||||
await expect(createPathAccessPolicy(workspace, { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
|
||||
await expect(createPathAccessPolicy(workspace, { allowedPaths: [fileRoot] })).rejects.toThrow("must be a directory");
|
||||
await expect(createPathAccessPolicy(workspace, { allowedPaths: ["relative/root"] })).rejects.toThrow("Allowed path must be absolute or start with ~");
|
||||
});
|
||||
|
||||
it("does not validate stale allowed roots for workspace-relative requests", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
await mkdir(workspace);
|
||||
await writeFile(join(workspace, "local.txt"), "local\n");
|
||||
|
||||
await expect(resolveWorkspacePathAccessTarget(workspace, "local.txt", { allowedPaths: [join(root, "missing")] })).resolves.toMatchObject({
|
||||
kind: "workspace",
|
||||
target: await realpath(join(workspace, "local.txt")),
|
||||
displayPath: "local.txt",
|
||||
});
|
||||
await expect(resolveWorkspacePathAccessTarget(workspace, join(workspace, "local.txt"), { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
|
||||
});
|
||||
|
||||
it("denies absolute targets outside allowed roots and through symlink escapes", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const allowed = join(root, "allowed");
|
||||
const secret = join(root, "secret");
|
||||
await mkdir(workspace);
|
||||
await mkdir(allowed);
|
||||
await mkdir(secret);
|
||||
await writeFile(join(secret, "token.txt"), "secret\n");
|
||||
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [allowed] });
|
||||
|
||||
await expect(resolvePathAccessTarget(policy, join(secret, "token.txt"))).rejects.toThrow("Path is outside allowed paths");
|
||||
|
||||
if (await trySymlink(secret, join(allowed, "escape"))) {
|
||||
await expect(resolvePathAccessTarget(policy, join(allowed, "escape", "token.txt"))).rejects.toThrow("Path is outside allowed paths");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows roots configured through symlinks by checking canonical paths", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const realAllowed = join(root, "real-allowed");
|
||||
const linkedAllowed = join(root, "linked-allowed");
|
||||
await mkdir(workspace);
|
||||
await mkdir(realAllowed);
|
||||
await writeFile(join(realAllowed, "data.txt"), "allowed\n");
|
||||
if (!await trySymlink(realAllowed, linkedAllowed)) return;
|
||||
|
||||
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [linkedAllowed] });
|
||||
|
||||
expect(policy.allowedRoots).toEqual([{ source: linkedAllowed, path: linkedAllowed, realPath: await realpath(realAllowed) }]);
|
||||
await expect(resolvePathAccessTarget(policy, join(linkedAllowed, "data.txt"))).resolves.toMatchObject({
|
||||
kind: "allowed",
|
||||
root: await realpath(realAllowed),
|
||||
target: await realpath(join(realAllowed, "data.txt")),
|
||||
displayPath: join(linkedAllowed, "data.txt"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function trySymlink(target: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await symlink(target, path, "dir");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { normalizeRelativePath } from "./pathSafety.js";
|
||||
|
||||
export interface AllowedPathRoot {
|
||||
/** Raw config value for diagnostics. */
|
||||
source: string;
|
||||
/** Host-absolute path after expanding ~ and normalizing syntax. */
|
||||
path: string;
|
||||
/** Canonical directory root used for containment checks. */
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export interface PathAccessPolicy {
|
||||
workspaceRoot: string;
|
||||
allowedRoots: AllowedPathRoot[];
|
||||
}
|
||||
|
||||
export type PathAccessTargetKind = "workspace" | "allowed";
|
||||
|
||||
export interface ResolvedPathAccessTarget {
|
||||
kind: PathAccessTargetKind;
|
||||
/** Canonical root that granted access: workspace root or allowed root. */
|
||||
root: string;
|
||||
/** Canonical existing target path. */
|
||||
target: string;
|
||||
/** Requestable path returned to clients and used to build child paths. */
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface PathAccessPolicyOptions {
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export async function createPathAccessPolicy(workspaceRootPath: string, pathAccess: PiWebPathAccessConfig | undefined, options: PathAccessPolicyOptions = {}): Promise<PathAccessPolicy> {
|
||||
return {
|
||||
workspaceRoot: await canonicalDirectory(workspaceRootPath, "Workspace path"),
|
||||
allowedRoots: await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveWorkspacePathAccessTarget(rootPath: string, requestedPath: string | undefined, pathAccess?: PiWebPathAccessConfig, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
|
||||
const request = requestedPath ?? "";
|
||||
const workspaceRoot = await canonicalDirectory(rootPath, "Workspace path");
|
||||
const allowedRoots = isAbsoluteishPath(request) ? await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options) : [];
|
||||
return resolvePathAccessTarget({ workspaceRoot, allowedRoots }, requestedPath, options);
|
||||
}
|
||||
|
||||
export async function resolvePathAccessTarget(policy: PathAccessPolicy, requestedPath: string | undefined, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
|
||||
const request = requestedPath ?? "";
|
||||
if (isAbsoluteishPath(request)) return resolveAllowedTarget(policy, request, options);
|
||||
|
||||
const displayPath = normalizeRelativePath(request);
|
||||
const target = await canonicalExistingPath(resolve(policy.workspaceRoot, displayPath));
|
||||
ensureInside(policy.workspaceRoot, target, "Path escapes workspace");
|
||||
return { kind: "workspace", root: policy.workspaceRoot, target, displayPath };
|
||||
}
|
||||
|
||||
export function isAbsoluteishPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || isAbsolute(path) || win32.isAbsolute(path);
|
||||
}
|
||||
|
||||
async function resolveAllowedRoots(allowedPaths: readonly string[], options: PathAccessPolicyOptions): Promise<AllowedPathRoot[]> {
|
||||
const roots: AllowedPathRoot[] = [];
|
||||
for (const source of allowedPaths) {
|
||||
const expanded = expandAbsoluteishPath(source, options, `Allowed path must be absolute or start with ~: ${source}`);
|
||||
const realPath = await canonicalDirectory(expanded, `Allowed path ${source}`);
|
||||
if (roots.some((root) => root.realPath === realPath)) continue;
|
||||
roots.push({ source, path: expanded, realPath });
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async function resolveAllowedTarget(policy: PathAccessPolicy, request: string, options: PathAccessPolicyOptions): Promise<ResolvedPathAccessTarget> {
|
||||
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
|
||||
|
||||
const displayPath = expandAbsoluteishPath(request, options, `Path is not absolute: ${request}`);
|
||||
const target = await canonicalExistingPath(displayPath);
|
||||
const root = policy.allowedRoots.find((allowedRoot) => isInsideOrSame(allowedRoot.realPath, target));
|
||||
if (root === undefined) throw new Error("Path is outside allowed paths");
|
||||
return { kind: "allowed", root: root.realPath, target, displayPath };
|
||||
}
|
||||
|
||||
function expandAbsoluteishPath(path: string, options: PathAccessPolicyOptions, relativeMessage: string): string {
|
||||
const home = options.homeDir ?? homedir();
|
||||
if (path === "~") return home;
|
||||
if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(home, path.slice(2));
|
||||
if (isAbsolute(path)) return resolve(path);
|
||||
if (win32.isAbsolute(path)) throw new Error(`Absolute path is not valid on this host: ${path}`);
|
||||
throw new Error(relativeMessage);
|
||||
}
|
||||
|
||||
async function canonicalDirectory(path: string, label: string): Promise<string> {
|
||||
const canonical = await canonicalExistingPath(path, `${label} does not exist`);
|
||||
const result = await stat(canonical);
|
||||
if (!result.isDirectory()) throw new Error(`${label} must be a directory`);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function canonicalExistingPath(path: string, missingMessage = "Path does not exist"): Promise<string> {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error(missingMessage, { cause: error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string, message: string): void {
|
||||
if (!isInsideOrSame(root, target)) throw new Error(message);
|
||||
}
|
||||
|
||||
function isInsideOrSame(root: string, target: string): boolean {
|
||||
const rel = relative(root, target);
|
||||
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
|
||||
export async function resolveInsideWorkspace(rootPath: string, relativePath: string | undefined): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
const joined = join(root, requested);
|
||||
const target = await realpath(joined).catch((error: unknown) => {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error("Path does not exist");
|
||||
throw error;
|
||||
});
|
||||
ensureInside(root, target);
|
||||
return { root, target, relativePath: requested };
|
||||
}
|
||||
|
||||
export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
@@ -30,10 +18,6 @@ export function normalizeRelativePath(input: string | undefined): string {
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string): void {
|
||||
const rel = relative(root, target);
|
||||
if (rel === "") return;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
|
||||
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-project-config-test-"));
|
||||
projectPath = join(tempDir, "project");
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("project PI WEB config", () => {
|
||||
it("returns an empty config when the project-local config is absent", async () => {
|
||||
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
|
||||
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
|
||||
exists: false,
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads project-local path access config", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
|
||||
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
|
||||
exists: true,
|
||||
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported project config versions", async () => {
|
||||
await writeProjectConfig({ version: 2 });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB project config version must be 1");
|
||||
});
|
||||
|
||||
it("reuses PI WEB path access schema validation", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: [""] } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
});
|
||||
|
||||
it("merges global and project path access in order", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
|
||||
|
||||
await expect(loadEffectiveProjectPathAccess(projectPath, { pathAccess: { allowedPaths: ["/global-sdk", "/shared"] } })).resolves.toEqual({
|
||||
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePathAccessConfigs", () => {
|
||||
it("returns undefined when no roots are configured", () => {
|
||||
expect(mergePathAccessConfigs(undefined, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deduplicates configured roots", () => {
|
||||
expect(mergePathAccessConfigs({ allowedPaths: ["/a", "/b"] }, { allowedPaths: ["/b", "/c"] })).toEqual({ allowedPaths: ["/a", "/b", "/c"] });
|
||||
});
|
||||
});
|
||||
|
||||
async function writeProjectConfig(value: unknown): Promise<void> {
|
||||
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
|
||||
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
|
||||
|
||||
export interface ProjectPiWebConfig {
|
||||
version?: 1;
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
}
|
||||
|
||||
export interface LoadedProjectPiWebConfig {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
config: ProjectPiWebConfig;
|
||||
}
|
||||
|
||||
export async function loadProjectPiWebConfig(projectPath: string): Promise<LoadedProjectPiWebConfig> {
|
||||
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isRecord(parsed)) throw new Error(`PI WEB project config must be a JSON object: ${path}`);
|
||||
return { path, exists: true, config: parseProjectPiWebConfig(parsed, path) };
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) return { path, exists: false, config: {} };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEffectiveProjectPathAccess(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebPathAccessConfig | undefined> {
|
||||
const projectConfig = await loadProjectPiWebConfig(projectPath);
|
||||
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
|
||||
}
|
||||
|
||||
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
|
||||
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
|
||||
return allowedPaths.length === 0 ? undefined : { allowedPaths };
|
||||
}
|
||||
|
||||
function parseProjectPiWebConfig(value: Record<string, unknown>, path: string): ProjectPiWebConfig {
|
||||
const version = value["version"];
|
||||
return {
|
||||
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProjectConfigVersion(value: unknown, path: string): 1 {
|
||||
if (value !== 1) throw new Error(`PI WEB project config version must be 1: ${path}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
function dedupe(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const PI_WEB_CAPABILITIES = {
|
||||
sessionsDeleteArchived: "sessions.deleteArchived",
|
||||
sessionsReload: "sessions.reload",
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
} as const;
|
||||
|
||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||
@@ -51,12 +52,18 @@ export interface PiWebPluginConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PiWebPathAccessConfig {
|
||||
allowedPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PiWebConfigValues {
|
||||
host?: string;
|
||||
port?: number;
|
||||
allowedHosts?: string[] | true;
|
||||
shortcuts?: PiWebShortcutConfig;
|
||||
plugins?: PiWebPluginConfigMap;
|
||||
/** External filesystem roots PI WEB may expose outside a workspace. */
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
/** Maximum accepted HTTP request body size in bytes (uploads/attachments). */
|
||||
maxUploadBytes?: number;
|
||||
/** When true, LLMs can start new sessions via the spawn_session tool. */
|
||||
|
||||
@@ -6,13 +6,14 @@ export type { PiWebCapability };
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[];
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/files" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
||||
|
||||
Reference in New Issue
Block a user