From 9870fa7ae81cba0ae251e86182f3d4df00fe18b9 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 19 Feb 2026 16:07:00 +0000 Subject: [PATCH] /workspaces refactor --- src/apps/officer-web/App.tsx | 3 +- .../Screens/Dashboard/Layout/Dock.tsx | 4 +- .../ServerSettings/TerminalSection.tsx | 20 -- .../Settings/ServerSettings/index.tsx | 4 +- .../Screens/Dashboard/Terminal/index.tsx | 5 - .../Dashboard/Workspaces/WorkspaceListApp.tsx | 75 +++--- .../Workspaces/WorkspaceListScreen.tsx | 99 ++++++-- .../Dashboard/Workspaces/WorkspaceScreen.tsx | 6 +- .../Dashboard/Workspaces/app-registry.tsx | 53 ++++- .../Screens/Dashboard/Workspaces/index.tsx | 2 +- .../officer-web/Screens/Dashboard/index.tsx | 2 +- .../officer-web/state/useServerSettings.ts | 5 +- .../officer-web/state/useWorkspacesState.ts | 39 ++++ src/server.tsx | 20 +- src/servers/api/settings/workspaces.ts | 197 ++++++++++++++++ src/servers/api/terminal/pty-sidecar.mjs | 126 +++++++--- src/servers/api/terminal/websocket.ts | 220 +++++++++--------- src/servers/data-path.ts | 4 + src/servers/hono.ts | 2 + src/workspaces/apps/Terminal/Terminal.tsx | 17 +- src/workspaces/helpers/slug.ts | 40 ++++ 21 files changed, 716 insertions(+), 227 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TerminalSection.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx create mode 100644 src/apps/officer-web/state/useWorkspacesState.ts create mode 100644 src/servers/api/settings/workspaces.ts create mode 100644 src/workspaces/helpers/slug.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 97c474dd..06acdded 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -48,13 +48,14 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 907afa21..3ea9b48c 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -109,12 +109,12 @@ export const Dock = ({ items, className }: DockProps) => { }; -import { MessageCircle, TerminalSquare, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react'; +import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react'; export const dockItems: DockItem[] = [ { label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, - { label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' }, + { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' }, { label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' }, diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TerminalSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TerminalSection.tsx deleted file mode 100644 index 17ad5c9b..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TerminalSection.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Switch } from '@/components/ui/switch'; -import { useServerSettings } from '@/state/useServerSettings'; - -export const TerminalSection = () => { - const { terminalSandboxed, saveSettings } = useServerSettings(); - - const toggleSandbox = (checked: boolean) => { - saveSettings({ terminalSandboxed: checked }); - }; - - return ( -
-
-
Sandbox terminal (Docker)
-
Restrict terminal access to the user's home directory.
-
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx index a9c65721..f59f348f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx @@ -1,17 +1,15 @@ import { useMemo } from 'react'; -import { Terminal, Shield, Server } from 'lucide-react'; +import { Terminal, Server } from 'lucide-react'; import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace'; import { appRegistry } from '../../Workspaces/app-registry'; import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel'; import { AIHarnessesSection } from './AIHarnessesSection'; -import { TerminalSection } from './TerminalSection'; const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED'; const sections: SettingsSection[] = [ { key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: }, - { key: 'terminal', icon: Shield, title: 'Terminal', description: 'Sandbox and access controls', content: }, ]; const { Sidebar, Content } = createSettingsPanelComponents({ diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx deleted file mode 100644 index 72ebdc9b..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TerminalView } from 'apps/Terminal'; - -export const Terminal = () => { - return ; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx index 6c128ea4..6f583ad3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx @@ -1,9 +1,11 @@ +import { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; -import { LayoutGrid, Plus, Pencil, Trash2 } from 'lucide-react'; +import { LayoutGrid, Plus, Pencil, Trash2, Search } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; -import { useUserState } from '@/state/useUserState'; +import { generateSlug } from 'helpers/slug'; +import { useWorkspacesState } from '@/state/useWorkspacesState'; import type { WorkspaceDefinition } from '@/components/Workspace'; import { SELECTED_WORKSPACE_KEY, @@ -19,7 +21,7 @@ export const WorkspaceListApp = () => { const navigate = useNavigate(); const client = useClient(); const queryClient = useQueryClient(); - const [workspaces, setWorkspaces] = useUserState('workspaces', []); + const [workspaces, setWorkspaces] = useWorkspacesState('workspaces', []); const [selected, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); const [, setCreating] = useGlobal(CREATING_WORKSPACE_KEY, false); const [, setEditing] = useGlobal(EDITING_WORKSPACE_KEY, null); @@ -27,7 +29,14 @@ export const WorkspaceListApp = () => { const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); const [, setTemplateIdx] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); + const [search, setSearch] = useState(''); const isWorkspacesPage = location.pathname === '/workspaces'; + const filtered = search + ? workspaces.filter((ws) => { + const q = search.toLowerCase(); + return [ws.name, ws.id, ws.description ?? '', ws.cwd ?? ''].some((field) => field.toLowerCase().includes(q)); + }) + : workspaces; const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => { ev.stopPropagation(); @@ -45,10 +54,10 @@ export const WorkspaceListApp = () => { if (selected === ws.id) setSelected(null); const layoutKey = `ws-layout-${ws.id}`; - const currentState = queryClient.getQueryData>(['USER_STATE']) ?? {}; + const currentState = queryClient.getQueryData>(['WORKSPACES_STATE']) ?? {}; const { [layoutKey]: _, ...rest } = currentState; - queryClient.setQueryData(['USER_STATE'], rest); - client.patch('/user/state', { [layoutKey]: null }).catch(() => {}); + queryClient.setQueryData(['WORKSPACES_STATE'], rest); + client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {}); }; const handleClick = (ws: WorkspaceDefinition) => { @@ -63,7 +72,7 @@ export const WorkspaceListApp = () => { return (
-
+
{ Workspaces +
-
- {workspaces.map((ws) => ( +
+ + setSearch(ev.target.value)} + placeholder="Search workspaces" + className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40" + /> +
+
+ {filtered.map((ws) => (
handleClick(ws)} > @@ -105,22 +140,10 @@ export const WorkspaceListApp = () => { )}
))} - {workspaces.length === 0 && ( -

No workspaces yet

- )} - {isWorkspacesPage && ( - + {filtered.length === 0 && ( +

+ {search ? 'No matches' : 'No workspaces yet'} +

)}
diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx index cf7530f1..cf154799 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx @@ -1,12 +1,14 @@ -import { Link, useNavigate } from 'react-router'; +import { Link, Navigate, useNavigate, useSearchParams } from 'react-router'; import { LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; import { useUserState } from '@/state/useUserState'; +import { useWorkspacesState } from '@/state/useWorkspacesState'; import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace'; import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace'; import { Button } from '@/components/ui/button'; +import { generateSlug, slugify } from 'helpers/slug'; import { appRegistry } from './app-registry'; import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants'; @@ -275,7 +277,7 @@ const CreatePanel = () => { const client = useClient(); const [name, setName] = useGlobal(NEW_WS_NAME_KEY, ''); const [filePath] = useUserState('files/currentPath', '/'); - const [, setWorkspaces] = useUserState('workspaces', []); + const [workspaces, setWorkspaces] = useWorkspacesState('workspaces', []); const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`; @@ -293,22 +295,49 @@ const CreatePanel = () => { const desc = description.trim(); if (isEditing) { + const existingIds = new Set(workspaces.filter((w) => w.id !== editingId).map((w) => w.id)); + let newId = slugify(trimmed) || generateSlug(); + while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`; + + const idChanged = newId !== editingId; + setWorkspaces((prev) => prev.map((ws) => ws.id === editingId - ? { ...ws, name: trimmed, description: desc || undefined, templateIdx } + ? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx } : ws, ), ); const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout(); - const layoutKey = `ws-layout-${editingId}`; - const currentState = queryClient.getQueryData>(['USER_STATE']) ?? {}; - queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout }); - client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {}); + const currentState = queryClient.getQueryData>(['WORKSPACES_STATE']) ?? {}; + const newLayoutKey = `ws-layout-${newId}`; + + if (idChanged) { + // Remove old keys from cache + const oldKeys = [`ws-layout-${editingId}`, `ws-terminals-${editingId}`, `ws-host-terminals-${editingId}`]; + const cleaned = { ...currentState }; + for (const k of oldKeys) delete cleaned[k]; + queryClient.setQueryData(['WORKSPACES_STATE'], { ...cleaned, [newLayoutKey]: wsLayout }); + + // Delete old files, write new layout + const patch: Record = { [newLayoutKey]: wsLayout }; + for (const k of oldKeys) patch[k] = null; + client.patch('/user/workspaces-state', patch).catch(() => {}); + } else { + queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout }); + client.patch('/user/workspaces-state', { [newLayoutKey]: wsLayout }).catch(() => {}); + } + + setEditingId(null); + setSelected(newId); } else { + const existingIds = new Set(workspaces.map((w) => w.id)); + let id = slugify(trimmed) || generateSlug(); + while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`; + const ws: WorkspaceDefinition = { - id: `ws-${Date.now()}`, + id, name: trimmed, cwd, description: desc || undefined, @@ -318,9 +347,9 @@ const CreatePanel = () => { const layoutKey = `ws-layout-${ws.id}`; setWorkspaces((prev) => [...prev, ws]); - const currentState = queryClient.getQueryData>(['USER_STATE']) ?? {}; - queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout }); - client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {}); + const currentState = queryClient.getQueryData>(['WORKSPACES_STATE']) ?? {}; + queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout }); + client.patch('/user/workspaces-state', { [layoutKey]: wsLayout }).catch(() => {}); setName(''); setDescription(''); @@ -408,15 +437,25 @@ const NewWorkspaceForm = () => ( const WorkspacePreviewEmpty = () => { const [creating, setCreating] = useGlobal(CREATING_WORKSPACE_KEY, false); const [editingId] = useGlobal(EDITING_WORKSPACE_KEY, null); + const [, setName] = useGlobal(NEW_WS_NAME_KEY, ''); + const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); + const [, setTemplateIdx] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); if (creating || editingId) return ; + const handleCreate = () => { + setName(generateSlug()); + setDescription(''); + setTemplateIdx(0); + setCreating(true); + }; + return (
-
- -

Select a workspace or create a new one

- @@ -426,14 +465,14 @@ const WorkspacePreviewEmpty = () => { }; const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => { - const [layout, setLayout] = useUserState(`ws-layout-${workspace.id}`, createDefaultLayout()); + const [layout, setLayout] = useWorkspacesState(`ws-layout-${workspace.id}`, createDefaultLayout()); return ; }; const WorkspacePreview = () => { const [selectedId] = useGlobal(SELECTED_WORKSPACE_KEY, null); - const [workspaces] = useUserState('workspaces', []); + const [workspaces] = useWorkspacesState('workspaces', []); const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null; if (!workspace) return ; @@ -454,7 +493,7 @@ const WorkspacePreview = () => { const registry = { ...appRegistry, 'workspace-preview': { name: 'Workspace Preview', icon: LayoutGrid, component: WorkspacePreview } }; export const WorkspaceListScreen = () => { - const [layout, setLayout, isLoaded] = useUserState('ws-layout-workspaces', defaultLayout); + const [layout, setLayout, isLoaded] = useWorkspacesState('ws-layout-ws-homepage', defaultLayout); if (!isLoaded) return null; @@ -464,3 +503,27 @@ export const WorkspaceListScreen = () => {
); }; + +export const NewWorkspaceRedirect = () => { + const [params] = useSearchParams(); + const [, setName] = useGlobal(NEW_WS_NAME_KEY, ''); + const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); + const [, setTemplateIdx] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); + const [, setCreating] = useGlobal(CREATING_WORKSPACE_KEY, false); + const [, setEditing] = useGlobal(EDITING_WORKSPACE_KEY, null); + const [, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); + const [, setFilePath] = useUserState('files/currentPath', '/'); + + const name = params.get('name'); + const cwd = params.get('cwd'); + + setSelected(null); + setEditing(null); + setName(name || generateSlug()); + setDescription(''); + setTemplateIdx(0); + setCreating(true); + if (cwd) setFilePath(cwd); + + return ; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx index 00c97695..196f4d33 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx @@ -1,12 +1,12 @@ import { useParams, Navigate } from 'react-router'; -import { useUserState } from '@/state/useUserState'; +import { useWorkspacesState } from '@/state/useWorkspacesState'; import { WorkspaceView, createDefaultLayout } from '@/components/Workspace'; import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace'; import { appRegistry } from './app-registry'; export const WorkspaceScreen = () => { const { id } = useParams<{ id: string }>(); - const [workspaces, , isLoaded] = useUserState('workspaces', []); + const [workspaces, , isLoaded] = useWorkspacesState('workspaces', []); const workspace = workspaces.find((ws) => ws.id === id); if (!isLoaded) return null; @@ -16,7 +16,7 @@ export const WorkspaceScreen = () => { }; const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => { - const [layout, setLayout] = useUserState(`ws-layout-${workspace.id}`, createDefaultLayout()); + const [layout, setLayout] = useWorkspacesState(`ws-layout-${workspace.id}`, createDefaultLayout()); return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx index 60ac6671..38fde2a6 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx @@ -1,8 +1,11 @@ -import { useState } from 'react'; -import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react'; +import { useAuth } from 'hooks/useAuth'; import type { AppRegistry } from '@/components/Workspace'; +import { useWorkspace } from '@/components/Workspace'; import { CodeEditorView } from 'apps/CodeEditor'; import { TerminalView } from 'apps/Terminal'; +import { useWorkspacesState } from '@/state/useWorkspacesState'; import { useClaude } from '../Chat/useClaude'; import { useOpenCode } from '../Chat/useOpenCode'; import { ChatPanel } from '../Chat/ChatPanel'; @@ -38,7 +41,50 @@ const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claud const CodeEditorWrapper = () => ; -const TerminalWrapper = () => ; +const TerminalWrapper = ({ panelId }: { panelId: string }) => { + const { workspaceId } = useWorkspace(); + const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default'; + const [terminals, setTerminals] = useWorkspacesState>(stateKey, {}); + + const sessionId = terminals[panelId]; + + useEffect(() => { + if (!sessionId) { + setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() })); + } + }, [panelId, sessionId, setTerminals]); + + if (!sessionId) return null; + + return ; +}; + +const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { + const { user } = useAuth(); + const { workspaceId } = useWorkspace(); + const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default'; + const [terminals, setTerminals] = useWorkspacesState>(stateKey, {}); + + const sessionId = terminals[panelId]; + + useEffect(() => { + if (!sessionId) { + setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() })); + } + }, [panelId, sessionId, setTerminals]); + + if (user?.role !== 'Super Admin') { + return ( +
+ Host Terminal requires Super Admin permissions. +
+ ); + } + + if (!sessionId) return null; + + return ; +}; export const appRegistry: AppRegistry = { 'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget }, @@ -47,6 +93,7 @@ export const appRegistry: AppRegistry = { 'sound-library': { name: 'Sound Library', icon: Music, component: () => }, 'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper }, 'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper }, + 'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper }, 'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => }, 'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => , fixedHeight: 180 }, 'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true }, diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx index 9f133b88..7fbf78c0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx @@ -1,2 +1,2 @@ -export { WorkspaceListScreen } from './WorkspaceListScreen'; +export { WorkspaceListScreen, NewWorkspaceRedirect } from './WorkspaceListScreen'; export { WorkspaceScreen } from './WorkspaceScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 4d32f14b..afccef7a 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -11,7 +11,7 @@ export * from './Automation'; export * from './Skills'; export * from './TaskLogs'; export * from './Tasks'; -export * from './Terminal'; + export * from './Files'; export * from './ChatHistory'; export * from './CodeEditor'; diff --git a/src/apps/officer-web/state/useServerSettings.ts b/src/apps/officer-web/state/useServerSettings.ts index f5b8cdda..de2a983d 100644 --- a/src/apps/officer-web/state/useServerSettings.ts +++ b/src/apps/officer-web/state/useServerSettings.ts @@ -12,7 +12,6 @@ type ServerSettings = { accountMode?: 'organization' | 'single'; aiHarnesses?: AIHarnesses; plugins?: Record; - terminalSandboxed?: boolean; }; const SETTINGS_KEY = ['SERVER_SETTINGS']; @@ -30,8 +29,6 @@ export const useServerSettings = () => { const accountMode = settings?.accountMode; const aiHarnesses = settings?.aiHarnesses; const plugins = settings?.plugins; - const terminalSandboxed = settings?.terminalSandboxed; - const saveSettings = useCallback( async (update: Partial) => { const result = await client.put('/server-settings', update); @@ -40,5 +37,5 @@ export const useServerSettings = () => { [client, queryClient], ); - return { onboardingComplete, accountMode, aiHarnesses, plugins, terminalSandboxed, isLoading, saveSettings }; + return { onboardingComplete, accountMode, aiHarnesses, plugins, isLoading, saveSettings }; }; diff --git a/src/apps/officer-web/state/useWorkspacesState.ts b/src/apps/officer-web/state/useWorkspacesState.ts new file mode 100644 index 00000000..f053a69f --- /dev/null +++ b/src/apps/officer-web/state/useWorkspacesState.ts @@ -0,0 +1,39 @@ +import { useCallback, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import { useAuth } from 'hooks/useAuth'; +import type { UserState } from './types/user-settings'; + +const QUERY_KEY = ['WORKSPACES_STATE']; + +export function useWorkspacesState(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] { + const client = useClient(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const { data: state = {}, isSuccess } = useQuery({ + queryKey: QUERY_KEY, + enabled: isAuthenticated, + queryFn: () => client.get('/user/workspaces-state'), + staleTime: Infinity, + }); + + const value = key in state ? (state[key] as T) : defaultValue; + + const setValue = useCallback( + (update: T | ((prev: T) => T)) => { + const currentState = queryClient.getQueryData(QUERY_KEY) ?? {}; + const currentValue = key in currentState ? (currentState[key] as T) : defaultValue; + const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update; + + queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue }); + + clientRef.current.patch('/user/workspaces-state', { [key]: newValue }).catch(() => {}); + }, + [key, defaultValue, queryClient], + ); + + return [value, setValue, isSuccess]; +} diff --git a/src/server.tsx b/src/server.tsx index 79cda01e..714cf3b7 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -7,12 +7,19 @@ import { verify } from './servers/jwt'; import { officerdb, TokenBlacklist } from 'officerdb'; import { claudeWebsocket } from './servers/api/claude/websocket'; import { opencodeWebsocket } from './servers/api/opencode/websocket'; -import { terminalWebsocket } from './servers/api/terminal/websocket'; +import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket'; import officerWeb from './apps/officer-web/index.html'; const { PORT = '5000' } = process.env; -type WSData = { userId: number; email: string; provider: 'claude' | 'opencode' | 'terminal' }; +type WSData = { + userId: number; + email: string; + role: string; + provider: 'claude' | 'opencode' | 'terminal'; + sandboxed: boolean; + sessionId?: string; +}; const handlers: Record = { claude: claudeWebsocket, @@ -35,7 +42,12 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco if (blacklisted) return new Response('Unauthorized', { status: 401 }); } - const ok = server.upgrade(req, { data: { userId: user.id, email: user.email, provider } }); + const url = new URL(req.url); + const sessionId = url.searchParams.get('sessionId') ?? undefined; + const sandboxed = url.searchParams.get('sandboxed') !== 'false'; + const ok = server.upgrade(req, { + data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId }, + }); if (!ok) return new Response('Upgrade failed', { status: 500 }); } catch { return new Response('Unauthorized', { status: 401 }); @@ -84,3 +96,5 @@ const server = serve({ }); console.log(`🚀 Server running at ${server.url}`); + +void initTerminalSidecars(); diff --git a/src/servers/api/settings/workspaces.ts b/src/servers/api/settings/workspaces.ts new file mode 100644 index 00000000..617c8ba4 --- /dev/null +++ b/src/servers/api/settings/workspaces.ts @@ -0,0 +1,197 @@ +import { mkdir, readdir, rename, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createRouter } from '../../create-router'; +import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile } from '@@/data-path'; + +export const workspacesRouter = createRouter(); + +type KeyMapping = { file: string; dir?: string }; + +type ResolveDirs = { wsDir: string; homepageDir: string }; + +function workspaceDir(dirs: ResolveDirs, id: string) { + return id === 'ws-homepage' ? dirs.homepageDir : join(dirs.wsDir, id); +} + +function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null { + if (key === 'workspaces') return { file: join(dirs.wsDir, 'index.json') }; + if (key === 'ws-terminals-default') return { file: join(dirs.wsDir, 'default-terminals.json') }; + if (key === 'ws-host-terminals-default') return { file: join(dirs.wsDir, 'default-host-terminals.json') }; + + const layoutMatch = key.match(/^ws-layout-(.+)$/); + if (layoutMatch) { + const id = layoutMatch[1]!; + const dir = workspaceDir(dirs, id); + return { file: join(dir, 'layout.json'), dir }; + } + + const terminalsMatch = key.match(/^ws-terminals-(.+)$/); + if (terminalsMatch) { + const id = terminalsMatch[1]!; + const dir = workspaceDir(dirs, id); + return { file: join(dir, 'terminals.json'), dir }; + } + + const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/); + if (hostTerminalsMatch) { + const id = hostTerminalsMatch[1]!; + const dir = workspaceDir(dirs, id); + return { file: join(dir, 'host-terminals.json'), dir }; + } + + return null; +} + +async function readJsonFile(path: string): Promise { + const file = Bun.file(path); + if (await file.exists()) return file.json(); + return null; +} + +async function writeJsonFile(path: string, data: unknown) { + await Bun.write(path, JSON.stringify(data, null, 2)); +} + +async function migrateFromState(email: string, dirs: ResolveDirs) { + const stateFile = getUserStateFile(email); + const file = Bun.file(stateFile); + if (!(await file.exists())) return; + + const state = (await file.json()) as Record; + const wsKeys = Object.keys(state).filter( + (k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'), + ); + if (wsKeys.length === 0) return; + + await mkdir(dirs.wsDir, { recursive: true }); + + for (const key of wsKeys) { + const migratedKey = key === 'ws-layout-workspaces' ? 'ws-layout-ws-homepage' : key; + const mapping = resolveKey(dirs, migratedKey); + if (!mapping) continue; + if (mapping.dir) await mkdir(mapping.dir, { recursive: true }); + await writeJsonFile(mapping.file, state[key]); + } + + const cleaned = { ...state }; + for (const key of wsKeys) delete cleaned[key]; + await Bun.write(stateFile, JSON.stringify(cleaned, null, 2)); +} + +async function migrateListLayout(dirs: ResolveDirs) { + const oldFile = join(dirs.wsDir, 'list-layout.json'); + if (!(await Bun.file(oldFile).exists())) return; + await mkdir(dirs.homepageDir, { recursive: true }); + await rename(oldFile, join(dirs.homepageDir, 'layout.json')); +} + +async function migrateHomepageFromWorkspaces(dirs: ResolveDirs) { + const oldDir = join(dirs.wsDir, 'ws-homepage'); + const layoutFile = join(oldDir, 'layout.json'); + if (!(await Bun.file(layoutFile).exists())) return; + await mkdir(dirs.homepageDir, { recursive: true }); + for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) { + const src = join(oldDir, name); + if (await Bun.file(src).exists()) { + await rename(src, join(dirs.homepageDir, name)); + } + } + const remaining = await readdir(oldDir); + if (remaining.length === 0) await rm(oldDir, { recursive: true, force: true }); +} + +async function readWorkspaceDir(dirPath: string, id: string, result: Record) { + const layout = await readJsonFile(join(dirPath, 'layout.json')); + if (layout !== null) result[`ws-layout-${id}`] = layout; + + const terminals = await readJsonFile(join(dirPath, 'terminals.json')); + if (terminals !== null) result[`ws-terminals-${id}`] = terminals; + + const hostTerminals = await readJsonFile(join(dirPath, 'host-terminals.json')); + if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals; +} + +async function readAllWorkspacesState(dirs: ResolveDirs): Promise> { + const result: Record = {}; + + const indexData = await readJsonFile(join(dirs.wsDir, 'index.json')); + if (indexData !== null) result['workspaces'] = indexData; + + const defaultTerminals = await readJsonFile(join(dirs.wsDir, 'default-terminals.json')); + if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals; + + const defaultHostTerminals = await readJsonFile(join(dirs.wsDir, 'default-host-terminals.json')); + if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals; + + // Read ws-homepage from its own dir + await readWorkspaceDir(dirs.homepageDir, 'ws-homepage', result); + + // Read per-workspace subdirs + let entries: import('node:fs').Dirent[] = []; + try { + entries = await readdir(dirs.wsDir, { withFileTypes: true }); + } catch { + return result; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result); + } + + return result; +} + +function getDirs(email: string): ResolveDirs { + return { wsDir: getUserWorkspacesDir(email), homepageDir: getUserHomepageWorkspaceDir(email) }; +} + +// GET /workspaces-state +workspacesRouter.get('/workspaces-state', async (ctx) => { + const email = ctx.get('user').email; + const dirs = getDirs(email); + + const dirFile = Bun.file(join(dirs.wsDir, 'index.json')); + if (!(await dirFile.exists())) { + await migrateFromState(email, dirs); + } + + await migrateListLayout(dirs); + await migrateHomepageFromWorkspaces(dirs); + + const state = await readAllWorkspacesState(dirs); + return ctx.json(state); +}); + +// PATCH /workspaces-state +workspacesRouter.patch('/workspaces-state', async (ctx) => { + const email = ctx.get('user').email; + const body = ctx.get('body') as Record; + const dirs = getDirs(email); + + await mkdir(dirs.wsDir, { recursive: true }); + + for (const [key, value] of Object.entries(body)) { + const mapping = resolveKey(dirs, key); + if (!mapping) continue; + + if (value === null) { + try { + await rm(mapping.file, { force: true }); + if (mapping.dir) { + const remaining = await readdir(mapping.dir); + if (remaining.length === 0) await rm(mapping.dir, { recursive: true, force: true }); + } + } catch { + // ignore + } + continue; + } + + if (mapping.dir) await mkdir(mapping.dir, { recursive: true }); + await writeJsonFile(mapping.file, value); + } + + const state = await readAllWorkspacesState(dirs); + return ctx.json(state); +}); diff --git a/src/servers/api/terminal/pty-sidecar.mjs b/src/servers/api/terminal/pty-sidecar.mjs index 8bd21ffc..6fbea184 100644 --- a/src/servers/api/terminal/pty-sidecar.mjs +++ b/src/servers/api/terminal/pty-sidecar.mjs @@ -15,6 +15,11 @@ const ohMyZshSource = isDocker ? '/opt/oh-my-zsh' : null; const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337'); const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1'; +const BUFFER_MAX = 50 * 1024; + +/** @type {Map} */ +const sessions = new Map(); + const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('terminal-sidecar'); @@ -50,7 +55,6 @@ const ensureUserFiles = async (homeDir) => { if (ohMyZshSource && existsSync(ohMyZshSource)) { await cp(ohMyZshSource, ohMyZshPath, { recursive: true }); } else { - // Clone oh-my-zsh on first run (host mode) const proc = Bun.spawn({ cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath], stdout: 'ignore', @@ -60,7 +64,6 @@ const ensureUserFiles = async (homeDir) => { } } - // Install starship on host if missing (not needed in Docker) if (!isDocker) { const starshipBin = join(homeDir, '.local', 'bin', 'starship'); if (!existsSync(starshipBin)) { @@ -75,20 +78,15 @@ const ensureUserFiles = async (homeDir) => { } }; -wss.on('connection', (ws) => { - let term = null; - let initialized = false; +const appendBuffer = (session, data) => { + session.buffer += data; + if (session.buffer.length > BUFFER_MAX) { + session.buffer = session.buffer.slice(-BUFFER_MAX); + } +}; - const cleanup = () => { - if (term) { - try { - term.kill(); - } catch { - // ignore - } - } - term = null; - }; +wss.on('connection', (ws) => { + let currentSessionId = null; ws.on('message', async (data) => { let msg; @@ -98,13 +96,58 @@ wss.on('connection', (ws) => { return; } - if (msg.type === 'init' && !initialized) { + if (msg.type === 'init') { + const sessionId = msg.sessionId; + if (!sessionId) return; + + currentSessionId = sessionId; + const existing = sessions.get(sessionId); + + console.log(`[sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`); + + if (existing) { + // Evict old WS if still attached + if (existing.ws && existing.ws !== ws) { + sendJson(existing.ws, { type: 'detached' }); + try { + existing.ws.close(); + } catch { + // ignore + } + } + + existing.ws = ws; + + // Replay buffer + if (existing.buffer.length > 0) { + sendJson(ws, { type: 'output', data: existing.buffer }); + } + + // Resize PTY to new client dimensions + const cols = msg.cols ?? existing.cols; + const rows = msg.rows ?? existing.rows; + if (cols > 0 && rows > 0 && (cols !== existing.cols || rows !== existing.rows)) { + existing.cols = cols; + existing.rows = rows; + try { + existing.term.resize(cols, rows); + } catch { + // ignore + } + } + + return; + } + + // New session — spawn PTY const shell = msg.shell ?? { command: '/bin/bash', args: ['-i'] }; const cwd = msg.cwd ?? process.cwd(); const homeDir = msg.homeDir ?? process.cwd(); const userLabel = msg.userLabel ?? 'officer'; const prompt = `${userLabel} in %~ %# `; - const bashPrompt = `${userLabel} \w \$ `; + const bashPrompt = `${userLabel} \\w \\$ `; + const cols = msg.cols ?? 80; + const rows = msg.rows ?? 24; try { await ensureUserFiles(homeDir); @@ -112,11 +155,12 @@ wss.on('connection', (ws) => { // ignore } + let term; try { term = pty.spawn(shell.command, shell.args ?? [], { name: 'xterm-256color', - cols: 80, - rows: 24, + cols, + rows, cwd, env: { ...process.env, @@ -139,37 +183,63 @@ wss.on('connection', (ws) => { return; } - initialized = true; + const session = { + term, + buffer: '', + cols, + rows, + ws, + initConfig: { shell, cwd, homeDir, userLabel }, + }; + sessions.set(sessionId, session); term.onData((output) => { - sendJson(ws, { type: 'output', data: output }); + appendBuffer(session, output); + if (session.ws) { + sendJson(session.ws, { type: 'output', data: output }); + } }); term.onExit(() => { - sendJson(ws, { type: 'exit' }); - cleanup(); + if (session.ws) { + sendJson(session.ws, { type: 'exit' }); + } + sessions.delete(sessionId); }); return; } - if (!term) return; + // Route other messages to current session + if (!currentSessionId) return; + const session = sessions.get(currentSessionId); + if (!session) return; switch (msg.type) { case 'input': - term.write(msg.data ?? ''); + session.term.write(msg.data ?? ''); break; case 'resize': - if (msg.cols > 0 && msg.rows > 0) term.resize(msg.cols, msg.rows); + if (msg.cols > 0 && msg.rows > 0) { + session.cols = msg.cols; + session.rows = msg.rows; + session.term.resize(msg.cols, msg.rows); + } break; case 'cwd': - if (msg.path) term.write(`cd ${JSON.stringify(msg.path)}\r`); + if (msg.path) session.term.write(`cd ${JSON.stringify(msg.path)}\r`); break; } }); ws.on('close', () => { - cleanup(); + // Detach WS from session — do NOT kill PTY + if (currentSessionId) { + const session = sessions.get(currentSessionId); + if (session && session.ws === ws) { + session.ws = null; + } + } }); }); diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index c8fbe060..ed78c5bc 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -1,19 +1,16 @@ import type { ServerWebSocket } from 'bun'; -import { mkdirSync, existsSync, statSync } from 'node:fs'; -import { dirname } from 'node:path'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { mkdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getHomeDir } from '@@/data-path'; +import { officerdb, Users } from 'officerdb'; -type WSData = { userId: number; email: string }; +type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string }; type ShellInfo = { command: string; args: string[]; name: string }; -type TerminalMode = 'host' | 'docker'; type BridgeSession = { client: ServerWebSocket; sidecar: WebSocket | null; - mode: TerminalMode; - dockerId?: string; + dockerId: string; port: number; }; @@ -24,49 +21,14 @@ type ContainerInfo = { port: number; }; +const HOST_SIDECAR_PORT = 5338; + const sessions = new Map, BridgeSession>(); -const defaultSidecarPort = Number(process.env.TERMINAL_PTY_PORT ?? '5337'); const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json'); -let sidecarProcess: Bun.Subprocess | null = null; let dockerImageReady = false; let containersCache: Record | null = null; - -const resolveShell = (): ShellInfo => { - const envShell = process.env.SHELL?.trim(); - if (envShell) { - const shellName = envShell.split('/').pop() ?? envShell; - return { - command: envShell, - args: shellName === 'zsh' ? ['-d', '-i'] : shellName === 'bash' ? ['-i'] : ['-i'], - name: shellName, - }; - } - - const candidates = ['/bin/zsh', '/usr/bin/zsh', '/bin/bash', '/usr/bin/bash']; - for (const candidate of candidates) { - if (existsSync(candidate)) { - const shellName = candidate.split('/').pop() ?? candidate; - return { - command: candidate, - args: shellName === 'zsh' ? ['-d', '-i'] : shellName === 'bash' ? ['-i'] : ['-i'], - name: shellName, - }; - } - } - - try { - const proc = Bun.spawnSync(['which', 'zsh'], { stdout: 'pipe', stderr: 'pipe' }); - if (proc.exitCode === 0) { - const command = proc.stdout.toString().trim(); - return { command, args: ['-d', '-i'], name: 'zsh' }; - } - } catch { - // fall through - } - - return { command: 'bash', args: ['-i'], name: 'bash' }; -}; +let hostSidecarProcess: ReturnType | null = null; const sendOutput = (ws: ServerWebSocket, data: string) => { try { @@ -76,25 +38,8 @@ const sendOutput = (ws: ServerWebSocket, data: string) => { } }; -const startSidecar = (port: number) => { - if (sidecarProcess) return; - const nodePath = Bun.which('node') ?? 'node'; - const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url)); - sidecarProcess = Bun.spawn({ - cmd: [nodePath, sidecarPath], - env: { ...process.env, TERMINAL_PTY_PORT: String(port), TERMINAL_PTY_HOST: '127.0.0.1' }, - stdout: 'inherit', - stderr: 'inherit', - }); - sidecarProcess.exited.then(() => { - sidecarProcess = null; - }); -}; - -const connectSidecar = async (port: number, mode: TerminalMode): Promise => { - if (mode === 'host') startSidecar(port); - - const delays = mode === 'docker' ? [200, 300, 500, 800, 1200, 1600, 2000] : [50, 150, 300, 600, 1200]; +const connectSidecar = async (port: number): Promise => { + const delays = [200, 300, 500, 800, 1200, 1600, 2000]; let lastError: Error | null = null; for (const delay of delays) { @@ -130,17 +75,6 @@ const connectSidecar = async (port: number, mode: TerminalMode): Promise => { - const settingsPath = `${homedir()}/.config/officer.dev/server-settings.json`; - return await Bun.file(settingsPath) - .json() - .catch(() => ({})); -}; - -const prebuildDockerImage = async () => { - ensureDockerImage(); -}; - const ensureDockerImage = () => { if (dockerImageReady) return; const dockerPath = Bun.which('docker'); @@ -284,48 +218,127 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str return next; }; -void prebuildDockerImage(); +const sidecarAlive = async (port: number): Promise => { + try { + const res = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) }); + return res.ok; + } catch { + return false; + } +}; + +const startHostSidecar = async () => { + if (await sidecarAlive(HOST_SIDECAR_PORT)) { + console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`); + return; + } + + if (hostSidecarProcess) { + hostSidecarProcess.kill(); + await hostSidecarProcess.exited.catch(() => {}); + hostSidecarProcess = null; + } + const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url)); + hostSidecarProcess = Bun.spawn({ + cmd: ['bun', sidecarPath], + env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) }, + stdout: 'inherit', + stderr: 'inherit', + }); + console.log(`[terminal] host sidecar started on port ${HOST_SIDECAR_PORT}`); +}; + +export const initTerminalSidecars = async () => { + await startHostSidecar(); + ensureDockerImage(); + const users = await officerdb.select({ id: Users.id, email: Users.email }).from(Users); + for (const user of users) { + const homeDir = getHomeDir(user.email); + mkdirSync(dirname(homeDir), { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + try { + await ensureDockerContainer(user.email, user.id, homeDir); + console.log(`[terminal] sidecar ready for ${user.email}`); + } catch (err) { + console.error(`[terminal] failed to start sidecar for ${user.email}:`, err); + } + } +}; + +const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' }; +const containerHome = '/home/officer'; export const terminalWebsocket = { async open(ws: ServerWebSocket) { - const { email } = ws.data; + const { email, role, sandboxed } = ws.data; + + if (!sandboxed && role !== 'Super Admin') { + sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n'); + return; + } + + if (!sandboxed) { + let sidecar: WebSocket | null = null; + try { + sidecar = await connectSidecar(HOST_SIDECAR_PORT); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to connect host sidecar'; + sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); + return; + } + + sessions.set(ws, { client: ws, sidecar, dockerId: '', port: HOST_SIDECAR_PORT }); + + sidecar.addEventListener('message', (ev) => { + try { + if (typeof ev.data === 'string') { + ws.send(ev.data); + } else { + ws.send(new TextDecoder().decode(ev.data)); + } + } catch { + // ws already closed + } + }); + + sidecar.send( + JSON.stringify({ + type: 'init', + sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, + shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, + cwd: process.env.HOME, + homeDir: process.env.HOME, + userLabel: email, + }), + ); + return; + } + const cwd = getHomeDir(email); const userRoot = dirname(cwd); mkdirSync(userRoot, { recursive: true }); mkdirSync(cwd, { recursive: true }); - const shell = resolveShell(); - const settings = await getSettings(); - const mode: TerminalMode = settings.terminalSandboxed ? 'docker' : 'host'; - const containerHome = '/home/officer'; - const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' }; - const port = - mode === 'docker' ? (await ensureDockerContainer(email, ws.data.userId, cwd)).port : defaultSidecarPort; - let sidecar: WebSocket | null = null; - let dockerId: string | undefined; + let info: ContainerInfo | undefined; try { - if (mode === 'docker') { - const info = await ensureDockerContainer(email, ws.data.userId, cwd); - dockerId = info.dockerId; - } - - sidecar = await connectSidecar(port, mode); + info = await ensureDockerContainer(email, ws.data.userId, cwd); + sidecar = await connectSidecar(info.port); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar'; sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); - if (dockerId) { - const logs = readDockerLogs(dockerId); + if (info) { + const logs = readDockerLogs(info.dockerId); if (logs) { sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`); } } sendOutput(ws, '\r\n[Process exited]\r\n'); - if (dockerId) stopDockerSidecar(dockerId); + if (info) stopDockerSidecar(info.dockerId); return; } - sessions.set(ws, { client: ws, sidecar, mode, dockerId, port }); + sessions.set(ws, { client: ws, sidecar, dockerId: info.dockerId, port: info.port }); sidecar.addEventListener('message', (ev) => { try { @@ -339,15 +352,13 @@ export const terminalWebsocket = { } }); - const initCwd = mode === 'docker' ? containerHome : cwd; - const initHome = mode === 'docker' ? containerHome : cwd; - const initShell = mode === 'docker' ? containerShell : shell; sidecar.send( JSON.stringify({ type: 'init', - shell: initShell, - cwd: initCwd, - homeDir: initHome, + sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`, + shell: containerShell, + cwd: containerHome, + homeDir: containerHome, userLabel: email, }), ); @@ -374,9 +385,6 @@ export const terminalWebsocket = { // ignore } } - if (session?.dockerId) { - // keep sandbox containers running for reuse - } sessions.delete(ws); }, diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 9ccb7727..1ad373e3 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -25,6 +25,10 @@ export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'se export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state.json'); +export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces'); + +export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage'); + export const getNativeSkillsDir = () => join(SEED_PATH, 'skills'); export const getGlobalSkillsDir = () => join(DATA_PATH, 'skills'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 6541f1d8..ba56c56c 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -16,6 +16,7 @@ import { sessionsRouter } from './api/sessions/sessions'; import { scrapeRouter } from './api/scrape/scrape'; import { uploadRouter } from './api/upload/upload'; import { settingsRouter } from './api/settings/settings'; +import { workspacesRouter } from './api/settings/workspaces'; import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { CustomError } from './custom-errors'; @@ -55,6 +56,7 @@ protectedRouter.route('/', opencodeModelsRouter); protectedRouter.route('/scrape', scrapeRouter); protectedRouter.route('/upload', uploadRouter); protectedRouter.route('/user', settingsRouter); +protectedRouter.route('/user', workspacesRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); diff --git a/src/workspaces/apps/Terminal/Terminal.tsx b/src/workspaces/apps/Terminal/Terminal.tsx index dae6f18e..fda8a7ec 100644 --- a/src/workspaces/apps/Terminal/Terminal.tsx +++ b/src/workspaces/apps/Terminal/Terminal.tsx @@ -16,6 +16,8 @@ export type TerminalViewProps = { className?: string; style?: CSSProperties; wsPath?: string; + sessionId?: string; + sandboxed?: boolean; fontSize?: number; fontFamily?: string; theme?: TerminalTheme; @@ -32,17 +34,22 @@ const DEFAULT_THEME: Required = { selectionBackground: '#3a3a5e', }; -const buildWsUrl = (wsPath: string) => { +const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean) => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = localStorage.getItem('BEARER_TOKEN') ?? ''; const separator = wsPath.includes('?') ? '&' : '?'; - return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`; + let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`; + if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`; + if (sandboxed === false) url += '&sandboxed=false'; + return url; }; export const TerminalView = ({ className, style, wsPath = '/api/terminal/ws', + sessionId, + sandboxed = true, fontSize = 14, fontFamily = 'Menlo, Monaco, "Courier New", monospace', theme, @@ -99,7 +106,7 @@ export const TerminalView = ({ fitAddonRef.current = fitAddon; onReadyRef.current?.(term); - const ws = new WebSocket(buildWsUrl(wsPath)); + const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed)); wsRef.current = ws; const handleOpen = () => { @@ -114,6 +121,8 @@ export const TerminalView = ({ } else if (msg.type === 'exit') { term.write('\r\n[Process exited]\r\n'); onExitRef.current?.(); + } else if (msg.type === 'detached') { + term.write('\r\n[Session taken over]\r\n'); } } catch { // ignore @@ -169,6 +178,8 @@ export const TerminalView = ({ }, [ isMounted, wsPath, + sessionId, + sandboxed, fontSize, fontFamily, background, diff --git a/src/workspaces/helpers/slug.ts b/src/workspaces/helpers/slug.ts new file mode 100644 index 00000000..266df062 --- /dev/null +++ b/src/workspaces/helpers/slug.ts @@ -0,0 +1,40 @@ +const words = [ + 'amber', 'anchor', 'apple', 'arrow', 'atlas', 'autumn', 'badge', 'basin', 'beacon', 'blade', + 'blaze', 'bloom', 'bolt', 'branch', 'brave', 'breeze', 'brick', 'bridge', 'brook', 'brush', + 'cairn', 'canvas', 'cargo', 'cedar', 'chain', 'chalk', 'chase', 'cherry', 'cipher', 'clash', + 'cliff', 'clock', 'cloud', 'clover', 'comet', 'coral', 'crane', 'creek', 'crest', 'crown', + 'crystal', 'curve', 'dagger', 'dawn', 'delta', 'depth', 'desert', 'dew', 'dice', 'dome', + 'dragon', 'drift', 'drum', 'dusk', 'eagle', 'echo', 'edge', 'ember', 'fable', 'falcon', + 'fern', 'field', 'flame', 'flare', 'flint', 'flora', 'forge', 'fossil', 'frost', 'galaxy', + 'garden', 'garnet', 'gate', 'glacier', 'glade', 'glaze', 'gleam', 'globe', 'gorge', 'grain', + 'grape', 'grove', 'gust', 'harbor', 'hawk', 'hazel', 'heart', 'hedge', 'helm', 'heron', + 'hive', 'hollow', 'honey', 'horizon', 'humble', 'index', 'inlet', 'iron', 'island', 'ivory', + 'jade', 'jasper', 'jewel', 'jungle', 'karma', 'kelp', 'kernel', 'kettle', 'lance', 'lantern', + 'latch', 'laurel', 'lava', 'leaf', 'ledge', 'lens', 'light', 'linen', 'lotus', 'lunar', + 'magic', 'maple', 'marble', 'marsh', 'mason', 'meadow', 'mesa', 'metal', 'mint', 'mirror', + 'mist', 'molar', 'moon', 'mortar', 'mosaic', 'moss', 'mural', 'nebula', 'nectar', 'nerve', + 'night', 'noble', 'north', 'novel', 'oasis', 'olive', 'onyx', 'orbit', 'orchid', 'origin', + 'otter', 'oxide', 'oyster', 'paddle', 'palm', 'panda', 'panel', 'pearl', 'pebble', 'pepper', + 'phase', 'pine', 'pixel', 'plain', 'plume', 'point', 'polar', 'pond', 'prism', 'pulse', + 'quartz', 'quest', 'quill', 'radar', 'rapid', 'raven', 'realm', 'reef', 'ridge', 'river', + 'robin', 'rocket', 'rouge', 'route', 'ruby', 'rustic', 'sage', 'sail', 'sand', 'satin', + 'scout', 'shell', 'shield', 'shore', 'sigma', 'silk', 'slate', 'slope', 'solar', 'spark', + 'spear', 'spice', 'spine', 'spoke', 'spring', 'sprout', 'stamp', 'steel', 'stone', 'storm', + 'stream', 'summit', 'swan', 'swift', 'talon', 'tangle', 'temple', 'terra', 'thistle', 'thorn', + 'thunder', 'tide', 'tiger', 'timber', 'torch', 'tower', 'trail', 'trend', 'tropic', 'tulip', + 'tunnel', 'umbra', 'valley', 'vapor', 'vault', 'velvet', 'venus', 'verge', 'vessel', 'vigor', + 'vine', 'violet', 'vista', 'vivid', 'vortex', 'wander', 'wave', 'wheat', 'willow', 'wind', + 'winter', 'wolf', 'zenith', 'zephyr', +]; + +const pick = () => words[Math.floor(Math.random() * words.length)]!; + +export const generateSlug = (count = 3) => Array.from({ length: count }, pick).join('-'); + +export const slugify = (text: string) => + text + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '');