diff --git a/web/src/components/ContextMenu.jsx b/web/src/components/ContextMenu.jsx
index 406b710..b9df6db 100644
--- a/web/src/components/ContextMenu.jsx
+++ b/web/src/components/ContextMenu.jsx
@@ -5,6 +5,9 @@ import {
PencilIcon,
TrashIcon,
EyeIcon,
+ ArrowDownTrayIcon,
+ ArrowUpTrayIcon,
+ ClipboardDocumentIcon,
} from '@heroicons/react/24/outline';
import { isFileOrPathInsideSymlink } from '../utils/helpers';
@@ -18,6 +21,9 @@ export default function ContextMenu({
onRename,
onDelete,
onView,
+ onDownload,
+ onUpload,
+ onCopyPath,
canModify,
childrenCache = {},
agentId = 'coo',
@@ -74,8 +80,58 @@ export default function ContextMenu({
)}
+ {/* Download option - files only */}
+ {!isDirectory && (
+
+ )}
+
+ {/* Upload option - directories only */}
+ {isDirectory && (
+
+ )}
+
+ {/* Copy path option - available to all users */}
+
+
{/* Separator */}
- {!isDirectory &&
}
+
{/* Modification options - visible to all, disabled for non-admin or symlinks */}
{/* New File - only for directories */}
diff --git a/web/src/components/WorkspaceExplorer.jsx b/web/src/components/WorkspaceExplorer.jsx
index 5bd0696..bc67953 100644
--- a/web/src/components/WorkspaceExplorer.jsx
+++ b/web/src/components/WorkspaceExplorer.jsx
@@ -14,6 +14,7 @@ import {
ChevronUpDownIcon,
EyeIcon,
EyeSlashIcon,
+ ArrowUpTrayIcon,
} from '@heroicons/react/24/outline';
import { useWorkspaceStore } from '../stores/workspaceStore';
import { useAuthStore } from '../stores/authStore';
@@ -27,6 +28,10 @@ import CreateFolderModal from './CreateFolderModal';
import RenameModal from './RenameModal';
import DeleteConfirmModal from './DeleteConfirmModal';
import { classNames, isPathInsideSymlink, isFileOrPathInsideSymlink } from '../utils/helpers';
+import {
+ extractAgentIdFromWorkspacePath,
+ isAbsoluteWorkspacePath,
+} from '../utils/workspacePaths';
import { useAgentStore } from '../stores/agentStore';
/**
@@ -74,6 +79,9 @@ export default function WorkspaceExplorer({
setWorkspaceRootPath,
clearErrors,
moveFile,
+ createFile,
+ updateFile,
+ fetchFileContent,
} = useWorkspaceStore();
const { isAdmin } = useAuthStore();
@@ -101,6 +109,8 @@ export default function WorkspaceExplorer({
// Context menu state
const [contextMenu, setContextMenu] = useState(null);
+ const [uploadTargetPath, setUploadTargetPath] = useState('/');
+ const fileUploadInputRef = useRef(null);
const canModify = isAdmin();
@@ -550,6 +560,192 @@ export default function WorkspaceExplorer({
setContextMenu(null);
};
+ const readBrowserFileAsBase64 = (browserFile) =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = String(reader.result || '');
+ const base64 = result.includes(',') ? result.split(',')[1] : result;
+ resolve(base64);
+ };
+ reader.onerror = () => reject(new Error(`Failed to read ${browserFile.name}`));
+ reader.readAsDataURL(browserFile);
+ });
+
+ const triggerUpload = (file) => {
+ const targetPath = file?.type === 'directory' ? file.path : currentPath;
+
+ if (isPathInsideSymlink(targetPath, childrenCache, agentId)) {
+ showToast('Cannot upload files inside symlink directories', 'error');
+ return;
+ }
+
+ if (!canModify) {
+ showToast('Admin access required to upload files', 'error');
+ return;
+ }
+
+ setUploadTargetPath(targetPath);
+ fileUploadInputRef.current?.click();
+ };
+
+ const handleUploadFiles = async (event) => {
+ const fileList = Array.from(event.target.files || []);
+ event.target.value = '';
+
+ if (!fileList.length) return;
+
+ if (isPathInsideSymlink(uploadTargetPath, childrenCache, agentId)) {
+ showToast('Cannot upload files inside symlink directories', 'error');
+ return;
+ }
+
+ let uploadedCount = 0;
+ let failedCount = 0;
+
+ for (const browserFile of fileList) {
+ const destinationPath =
+ uploadTargetPath === '/'
+ ? `/${browserFile.name}`
+ : `${uploadTargetPath}/${browserFile.name}`;
+ const rawPath = isAbsoluteWorkspacePath(destinationPath);
+ const destinationAgentId = extractAgentIdFromWorkspacePath(destinationPath) || agentId;
+
+ try {
+ const base64Content = await readBrowserFileAsBase64(browserFile);
+
+ try {
+ await createFile({
+ path: destinationPath,
+ content: base64Content,
+ encoding: 'base64',
+ agentId: destinationAgentId,
+ rawPath,
+ });
+ } catch (error) {
+ const isConflict =
+ error?.message?.includes('already exists') || error?.message?.includes('FILE_EXISTS');
+
+ if (!isConflict) {
+ throw error;
+ }
+
+ const shouldOverwrite = window.confirm(
+ `"${browserFile.name}" already exists. Overwrite it?`,
+ );
+ if (!shouldOverwrite) {
+ failedCount += 1;
+ continue;
+ }
+
+ await updateFile({
+ path: destinationPath,
+ content: base64Content,
+ encoding: 'base64',
+ agentId: destinationAgentId,
+ rawPath,
+ });
+ }
+
+ uploadedCount += 1;
+ } catch {
+ failedCount += 1;
+ }
+ }
+
+ // Refresh absolute workspace directories without re-prepending workspaceRootPath.
+ await fetchListing({
+ path: uploadTargetPath,
+ recursive: false,
+ force: true,
+ agentId: extractAgentIdFromWorkspacePath(uploadTargetPath) || agentId,
+ rawPath: isAbsoluteWorkspacePath(uploadTargetPath),
+ });
+
+ if (uploadedCount > 0) {
+ showToast(
+ failedCount > 0
+ ? `Uploaded ${uploadedCount} file(s), ${failedCount} failed`
+ : `Uploaded ${uploadedCount} file(s)`,
+ failedCount > 0 ? 'info' : 'success',
+ );
+ } else {
+ showToast('No files were uploaded', 'error');
+ }
+ };
+
+ const decodeBase64ToUint8Array = (value) => {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+ };
+
+ const handleDownloadFile = async (file) => {
+ if (!file || file.type !== 'file') return;
+
+ try {
+ const isWorkspacePath = isAbsoluteWorkspacePath(file.path);
+ const fileAgentId =
+ file.agentId ||
+ (isWorkspacePath ? extractAgentIdFromWorkspacePath(file.path) : null) ||
+ agentId;
+ const fileData = await fetchFileContent({
+ path: file.path,
+ force: true,
+ agentId: fileAgentId,
+ rawPath: !!file.fullPath || isWorkspacePath,
+ });
+ const encoding = fileData?.encoding || 'utf8';
+ const rawContent = fileData?.content || '';
+
+ const blob =
+ encoding === 'base64'
+ ? new Blob([decodeBase64ToUint8Array(rawContent)], {
+ type: 'application/octet-stream',
+ })
+ : new Blob([rawContent], { type: 'text/plain;charset=utf-8' });
+
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = file.name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ showToast(`Downloaded ${file.name}`, 'success');
+ } catch (error) {
+ showToast(error?.message || 'Failed to download file', 'error');
+ }
+ };
+
+ const toWorkspaceRelativePath = (filePath) => {
+ if (!filePath || typeof filePath !== 'string') return null;
+
+ // Preserve workspace-root relative paths as-is (e.g. /docs/README.md)
+ if (!filePath.startsWith('/workspace')) return filePath;
+
+ // If path includes a virtual workspace root prefix (e.g. /workspace-main/foo),
+ // strip it to keep clipboard values portable across pods/hosts.
+ const stripped = filePath.replace(/^\/workspace(?:-[^/]+)?/, '');
+ return stripped || '/';
+ };
+
+ const handleCopyPath = async (file) => {
+ const workspaceRelativePath = toWorkspaceRelativePath(file?.path);
+ if (!workspaceRelativePath) return;
+
+ try {
+ await navigator.clipboard.writeText(workspaceRelativePath);
+ showToast('Workspace path copied to clipboard', 'success');
+ } catch {
+ showToast('Failed to copy workspace path', 'error');
+ }
+ };
+
// CRUD handlers
const handleNewFile = (file) => {
const targetPath = file?.type === 'directory' ? file.path : currentPath;
@@ -867,6 +1063,21 @@ export default function WorkspaceExplorer({
New Folder
+
{/* Search */}
@@ -1115,12 +1326,23 @@ export default function WorkspaceExplorer({
onRename={handleRename}
onDelete={handleDelete}
onView={handleView}
+ onDownload={handleDownloadFile}
+ onUpload={triggerUpload}
+ onCopyPath={handleCopyPath}
canModify={canModify}
childrenCache={childrenCache}
agentId={agentId}
/>
)}
+
+
{/* Modals */}
({
},
// Fetch workspace file listing
- fetchListing: async ({ path = '/', recursive = false, force = false, agentId = 'coo' }) => {
+ fetchListing: async ({
+ path = '/',
+ recursive = false,
+ force = false,
+ agentId = 'coo',
+ rawPath = false,
+ }) => {
const state = get();
const rootPath = state.workspaceRootPath;
- const fullPath = rootPath && !path.startsWith(rootPath) ? `${rootPath}${path}` : path;
+ const fullPath =
+ rawPath || !rootPath || path.startsWith(rootPath) ? path : `${rootPath}${path}`;
const cacheKey = `${agentId}:${path}:${recursive}`;
// Return cached if available and not forced
@@ -265,11 +272,12 @@ export const useWorkspaceStore = create((set, get) => ({
},
// Create a new file
- createFile: async ({ path, content = '', encoding = 'utf8', agentId = 'coo' }) => {
+ createFile: async ({ path, content = '', encoding = 'utf8', agentId = 'coo', rawPath = false }) => {
try {
const state = get();
const rootPath = state.workspaceRootPath;
- const fullPath = rootPath && !path.startsWith(rootPath) ? `${rootPath}${path}` : path;
+ const fullPath =
+ rawPath || !rootPath || path.startsWith(rootPath) ? path : `${rootPath}${path}`;
const response = await api.post('/openclaw/workspace/files', {
path: fullPath,
@@ -291,11 +299,12 @@ export const useWorkspaceStore = create((set, get) => ({
},
// Update an existing file
- updateFile: async ({ path, content, encoding = 'utf8', agentId = 'coo' }) => {
+ updateFile: async ({ path, content, encoding = 'utf8', agentId = 'coo', rawPath = false }) => {
try {
const state = get();
const rootPath = state.workspaceRootPath;
- const fullPath = rootPath && !path.startsWith(rootPath) ? `${rootPath}${path}` : path;
+ const fullPath =
+ rawPath || !rootPath || path.startsWith(rootPath) ? path : `${rootPath}${path}`;
const response = await api.put('/openclaw/workspace/files', {
path: fullPath,
diff --git a/web/src/stores/workspaceStore.test.js b/web/src/stores/workspaceStore.test.js
index 85c469f..2b22d29 100644
--- a/web/src/stores/workspaceStore.test.js
+++ b/web/src/stores/workspaceStore.test.js
@@ -114,6 +114,20 @@ describe('workspaceStore', () => {
expect(result.files[1].path).toBe('/external/file.txt');
});
+ it('honors rawPath for absolute listing refreshes', async () => {
+ useWorkspaceStore.getState().setWorkspaceRootPath('/workspaces/main');
+ api.get.mockResolvedValue({ data: { data: { files: [] } } });
+
+ await useWorkspaceStore.getState().fetchListing({
+ path: '/workspace-coo/docs',
+ rawPath: true,
+ });
+
+ expect(api.get).toHaveBeenCalledWith('/openclaw/workspace/files', {
+ params: { path: '/workspace-coo/docs', recursive: 'false' },
+ });
+ });
+
it('sets listing error with fallback message when request fails', async () => {
api.get.mockRejectedValue(new Error('Network down'));
@@ -263,6 +277,23 @@ describe('workspaceStore', () => {
expect(useWorkspaceStore.getState().listings['coo:/src:true']).toBeUndefined();
});
+ it('createFile honors rawPath for absolute workspace destinations', async () => {
+ useWorkspaceStore.getState().setWorkspaceRootPath('/workspaces/main');
+ api.post.mockResolvedValue({ data: { data: { path: '/workspace-coo/src/new.js' } } });
+
+ await useWorkspaceStore.getState().createFile({
+ path: '/workspace-coo/src/new.js',
+ content: 'console.log(1)',
+ rawPath: true,
+ });
+
+ expect(api.post).toHaveBeenCalledWith('/openclaw/workspace/files', {
+ path: '/workspace-coo/src/new.js',
+ content: 'console.log(1)',
+ encoding: 'utf8',
+ });
+ });
+
it('updateFile updates content and invalidates related caches', async () => {
useWorkspaceStore.setState({
listings: {
@@ -291,6 +322,23 @@ describe('workspaceStore', () => {
expect(useWorkspaceStore.getState().listings['coo:/src:true']).toBeUndefined();
});
+ it('updateFile honors rawPath for absolute workspace destinations', async () => {
+ useWorkspaceStore.getState().setWorkspaceRootPath('/workspaces/main');
+ api.put.mockResolvedValue({ data: { data: { updated: true } } });
+
+ await useWorkspaceStore.getState().updateFile({
+ path: '/workspace-coo/src/new.js',
+ content: 'new',
+ rawPath: true,
+ });
+
+ expect(api.put).toHaveBeenCalledWith('/openclaw/workspace/files', {
+ path: '/workspace-coo/src/new.js',
+ content: 'new',
+ encoding: 'utf8',
+ });
+ });
+
it('deleteFile clears selected file and caches', async () => {
useWorkspaceStore.setState({
selectedFile: { path: '/src/new.js' },
diff --git a/web/src/utils/workspacePaths.js b/web/src/utils/workspacePaths.js
new file mode 100644
index 0000000..2c392b9
--- /dev/null
+++ b/web/src/utils/workspacePaths.js
@@ -0,0 +1,11 @@
+// Absolute workspace paths bypass the store's workspaceRootPath prefixing.
+export const isAbsoluteWorkspacePath = (value) =>
+ typeof value === 'string' &&
+ (value.startsWith('/workspace-') || value.startsWith('/workspace/'));
+
+export const extractAgentIdFromWorkspacePath = (value) => {
+ if (typeof value !== 'string') return null;
+
+ const match = value.match(/^\/workspace-([^/]+)(?:\/|$)/);
+ return match ? match[1] : null;
+};
diff --git a/web/src/utils/workspacePaths.test.js b/web/src/utils/workspacePaths.test.js
new file mode 100644
index 0000000..9ee45a7
--- /dev/null
+++ b/web/src/utils/workspacePaths.test.js
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { extractAgentIdFromWorkspacePath, isAbsoluteWorkspacePath } from './workspacePaths';
+
+describe('workspacePaths', () => {
+ describe('isAbsoluteWorkspacePath', () => {
+ it('detects absolute agent workspace prefixes', () => {
+ expect(isAbsoluteWorkspacePath('/workspace-coo/docs/guide.md')).toBe(true);
+ expect(isAbsoluteWorkspacePath('/workspace/docs/guide.md')).toBe(true);
+ });
+
+ it('rejects workspace-relative paths', () => {
+ expect(isAbsoluteWorkspacePath('/docs/guide.md')).toBe(false);
+ expect(isAbsoluteWorkspacePath('docs/guide.md')).toBe(false);
+ expect(isAbsoluteWorkspacePath(null)).toBe(false);
+ });
+ });
+
+ describe('extractAgentIdFromWorkspacePath', () => {
+ it('extracts the agent id from absolute agent workspace paths', () => {
+ expect(extractAgentIdFromWorkspacePath('/workspace-coo/docs/guide.md')).toBe('coo');
+ expect(extractAgentIdFromWorkspacePath('/workspace-lead')).toBe('lead');
+ });
+
+ it('returns null for shared or relative paths', () => {
+ expect(extractAgentIdFromWorkspacePath('/workspace/docs/guide.md')).toBe(null);
+ expect(extractAgentIdFromWorkspacePath('/docs/guide.md')).toBe(null);
+ expect(extractAgentIdFromWorkspacePath(undefined)).toBe(null);
+ });
+ });
+});