eigent/electron/main/localPathActions.ts
Douglas Lai 1edaab9819
feat: make the Files page a usable project workspace (#1859)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:09:02 +08:00

194 lines
6.3 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import fsp from 'node:fs/promises';
import {
authorizeLocalFilePath,
isExecutableBundlePath,
isExecutableExternalOpenPath,
} from './localFileSecurity';
export type LocalPathActionResult =
| { success: true }
| { success: false; error: string };
export type AuthorizedLocalNode = {
success: true;
path: string;
kind: 'file' | 'directory';
mode: number;
};
type LocalNodeAuthorizationResult =
| AuthorizedLocalNode
| { success: false; error: string };
export interface LocalPathShell {
openPath: (targetPath: string) => Promise<string>;
showItemInFolder: (targetPath: string) => void;
}
/** Remember an exact real path with bounded least-recently-used eviction. */
export function rememberBoundedPathGrant(
grants: Set<string>,
realPath: string,
limit: number
): void {
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new Error('Path grant limit must be a positive safe integer');
}
grants.delete(realPath);
grants.add(realPath);
while (grants.size > limit) {
const oldest = grants.values().next().value;
if (oldest === undefined) break;
grants.delete(oldest);
}
}
function authorizationError(reason: 'invalid' | 'missing' | 'outside-roots') {
if (reason === 'invalid') return 'Invalid local path';
if (reason === 'missing') return 'Path does not exist';
return 'Path is outside the active workspace';
}
/** Classify an already-authorized real path as a revealable file or folder. */
async function describeLocalNode(
realPath: string
): Promise<LocalNodeAuthorizationResult> {
const stats = await fsp.stat(realPath).catch(() => null);
if (!stats) return { success: false, error: 'Path does not exist' };
if (!stats.isFile() && !stats.isDirectory()) {
return { success: false, error: 'Unsupported local path type' };
}
return {
success: true,
path: realPath,
kind: stats.isDirectory() ? 'directory' : 'file',
mode: stats.mode,
};
}
/** Resolve and authorize one existing file-system node inside an active Space. */
export async function authorizeWorkspaceLocalNode(
targetPath: string,
workspaceRoots: Iterable<string>
): Promise<LocalNodeAuthorizationResult> {
const authorization = await authorizeLocalFilePath(
targetPath,
workspaceRoots
);
if (!authorization.allowed) {
return {
success: false,
error: authorizationError(authorization.reason),
};
}
return describeLocalNode(authorization.filePath);
}
/**
* Authorize a node the renderer may reveal in the OS file manager.
*
* Two independent grants are accepted: containment in an active Space root,
* and an exact path the user themselves chose in the native file dialog. The
* second grant keeps chat attachments stored outside the workspace revealable
* without widening the workspace boundary for every other operation.
*/
export async function authorizeRevealableLocalNode(
targetPath: string,
workspaceRoots: Iterable<string>,
userSelectedPaths: ReadonlySet<string> = new Set()
): Promise<LocalNodeAuthorizationResult> {
const workspaceNode = await authorizeWorkspaceLocalNode(
targetPath,
workspaceRoots
);
if (workspaceNode.success || userSelectedPaths.size === 0) {
return workspaceNode;
}
const realPath = await fsp.realpath(targetPath).catch(() => null);
if (!realPath || !userSelectedPaths.has(realPath)) return workspaceNode;
return describeLocalNode(realPath);
}
/** Open a folder itself, or reveal and highlight an exact file. */
export async function revealAuthorizedLocalNode(
node: AuthorizedLocalNode,
targetShell: LocalPathShell
): Promise<LocalPathActionResult> {
// A macOS `.app` is a directory, so `shell.openPath` would launch it rather
// than browse it. Bundles are highlighted in their parent folder instead,
// exactly like files -- revealing never executes agent-authored output.
if (node.kind === 'file' || isExecutableBundlePath(node.path)) {
targetShell.showItemInFolder(node.path);
return { success: true };
}
const error = await targetShell.openPath(node.path);
return error ? { success: false, error } : { success: true };
}
export async function revealWorkspaceLocalNode(
targetPath: string,
workspaceRoots: Iterable<string>,
targetShell: LocalPathShell
): Promise<LocalPathActionResult> {
const node = await authorizeWorkspaceLocalNode(targetPath, workspaceRoots);
if (!node.success) return node;
return revealAuthorizedLocalNode(node, targetShell);
}
/** Reveal a Space file or a path the user picked in the native file dialog. */
export async function revealUserVisibleLocalNode(
targetPath: string,
workspaceRoots: Iterable<string>,
userSelectedPaths: ReadonlySet<string>,
targetShell: LocalPathShell
): Promise<LocalPathActionResult> {
const node = await authorizeRevealableLocalNode(
targetPath,
workspaceRoots,
userSelectedPaths
);
if (!node.success) return node;
return revealAuthorizedLocalNode(node, targetShell);
}
/** Open a non-executable workspace file with its OS-default application. */
export async function openWorkspaceLocalFile(
targetPath: string,
workspaceRoots: Iterable<string>,
targetShell: LocalPathShell
): Promise<LocalPathActionResult> {
const node = await authorizeWorkspaceLocalNode(targetPath, workspaceRoots);
if (!node.success) return node;
if (node.kind !== 'file') {
return { success: false, error: 'Path is not a file' };
}
if (isExecutableExternalOpenPath(node.path, node.mode)) {
targetShell.showItemInFolder(node.path);
return {
success: false,
error: 'Executable files cannot be opened from an agent result',
};
}
const error = await targetShell.openPath(node.path);
return error ? { success: false, error } : { success: true };
}