diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index a363f95e..6aa83cd5 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -4,7 +4,7 @@ import { useServerSettings } from '@/state/useServerSettings'; import { useInitialData } from '@/state/useInitialData'; import { LandingPage, AuthLayout } from './Screens/LandingPage'; import { Home } from './Screens/Dashboard/Home'; -import { Profile } from './Screens/Dashboard/Profile'; +import { ProfileSettings } from './Screens/Dashboard/Settings/ProfileSettings'; import { ClaudeSessions, ClaudeChat, OpenCodeChat, NewChat } from './Screens/Dashboard/Chat'; import { Plans } from './Screens/Dashboard/Plans'; import { Skills } from './Screens/Dashboard/Skills'; @@ -15,8 +15,8 @@ import { SignoutScreen } from './Screens/Dashboard/SignoutScreen'; import { Screen as Files } from 'plugins/FileBrowser/client'; import { Screen as Terminal } from 'plugins/Terminal/client'; import { AISettings } from './Screens/Dashboard/Settings/AISettings'; -import { ServerSettings } from './Screens/Dashboard/ServerSettings'; -import { Applications } from './Screens/Dashboard/Applications'; +import { ServerSettings } from './Screens/Dashboard/Settings/ServerSettings'; +import { ResourceSettings } from './Screens/Dashboard/Settings/ResourceSettings'; import { OnboardingAdmin } from './Screens/Dashboard/OnboardingAdmin'; export function App() { @@ -45,7 +45,10 @@ export function App() { {isAuthenticated && onboardingComplete && ( } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -53,9 +56,6 @@ export function App() { } /> {plugins?.FileBrowser !== false && } />} {plugins?.Terminal !== false && } />} - } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout.tsx index 601258dc..91522d2b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout.tsx @@ -114,9 +114,9 @@ export function DashboardLayout({ children, mobileFull }: DashboardLayoutProps) - + - Applications + Resources diff --git a/src/apps/officer-web/Screens/Dashboard/Profile/ChangePassword.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ChangePassword.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/Profile/ChangePassword.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ChangePassword.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Profile/Languages.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/Profile/Languages.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Profile/TaskDefaults.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/TaskDefaults.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/Profile/TaskDefaults.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/TaskDefaults.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Profile/UserData.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/UserData.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/Profile/UserData.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/UserData.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Profile/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx similarity index 97% rename from src/apps/officer-web/Screens/Dashboard/Profile/index.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx index b3da9c24..edc27261 100644 --- a/src/apps/officer-web/Screens/Dashboard/Profile/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx @@ -3,7 +3,7 @@ import { Search, User, Lock, Globe, ListChecks } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Card } from '@/components/Card'; -import { DashboardLayout } from '../Layout'; +import { DashboardLayout } from '../../Layout'; import { UserData } from './UserData'; import { ChangePassword } from './ChangePassword'; import { Languages } from './Languages'; @@ -42,7 +42,7 @@ const sections = [ const allKeys = sections.map((s) => s.key); -export const Profile = () => { +export const ProfileSettings = () => { const [search, setSearch] = useState(''); const [expanded, setExpanded] = useState([]); const sectionRefs = useRef>({}); diff --git a/src/apps/officer-web/Screens/Dashboard/Applications/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx similarity index 98% rename from src/apps/officer-web/Screens/Dashboard/Applications/index.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx index 594bb9cb..024dc351 100644 --- a/src/apps/officer-web/Screens/Dashboard/Applications/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/Card'; import { useClient } from 'hooks/useClient'; -import { DashboardLayout } from '../Layout'; +import { DashboardLayout } from '../../Layout'; type AppStatus = { id: string; @@ -43,7 +43,7 @@ const CopyCommand = ({ command }: { command: string }) => { ); }; -export const Applications = () => { +export const ResourceSettings = () => { const client = useClient(); const queryClient = useQueryClient(); const [actionInProgress, setActionInProgress] = useState(null); diff --git a/src/apps/officer-web/Screens/Dashboard/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/ServerSettings/AIHarnessesSection.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ServerSettings/PluginsSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/PluginsSection.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/ServerSettings/PluginsSection.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/PluginsSection.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ServerSettings/TerminalSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TerminalSection.tsx similarity index 100% rename from src/apps/officer-web/Screens/Dashboard/ServerSettings/TerminalSection.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TerminalSection.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ServerSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx similarity index 98% rename from src/apps/officer-web/Screens/Dashboard/ServerSettings/index.tsx rename to src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx index d0169fae..51aca995 100644 --- a/src/apps/officer-web/Screens/Dashboard/ServerSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx @@ -3,7 +3,7 @@ import { Search, Terminal, Puzzle, Shield } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Card } from '@/components/Card'; -import { DashboardLayout } from '../Layout'; +import { DashboardLayout } from '../../Layout'; import { AIHarnessesSection } from './AIHarnessesSection'; import { PluginsSection } from './PluginsSection'; import { TerminalSection } from './TerminalSection'; diff --git a/src/workspaces/plugins/Terminal/TERMINAL_PLUGIN.md b/src/workspaces/plugins/Terminal/TERMINAL_PLUGIN.md new file mode 100644 index 00000000..b93db9f6 --- /dev/null +++ b/src/workspaces/plugins/Terminal/TERMINAL_PLUGIN.md @@ -0,0 +1,308 @@ +# Terminal Plugin Spec + +This document describes the Terminal plugin implementation, architecture, and integration points. It is a reference for continuing work later. + +## Goals + +- Provide a browser terminal (xterm.js) inside Officer. +- Support two execution modes: + - Host mode: shell runs on the host machine. + - Sandbox mode: shell runs inside a Docker sidecar container. +- Keep host mode working and optional; sandbox is enabled via admin settings. +- Keep all terminal logic self-contained in the plugin. +- Allow the terminal UI to be rendered anywhere with a reusable component. + +## File Layout + +- `src/workspaces/plugins/Terminal/index.ts` + - Exports plugin metadata, client screen, and server websocket handler. +- `src/workspaces/plugins/Terminal/client/TerminalView.tsx` + - Reusable terminal component (xterm + websocket bridge). +- `src/workspaces/plugins/Terminal/client/Screen/index.tsx` + - Page wrapper that renders `TerminalView` inside `DashboardLayout`. +- `src/workspaces/plugins/Terminal/client/index.ts` + - Exports `Screen` and `TerminalView`. +- `src/workspaces/plugins/Terminal/server/websocket.ts` + - WebSocket handler for terminal sessions and sidecar orchestration. +- `src/workspaces/plugins/Terminal/server/pty-sidecar.mjs` + - Node sidecar process that owns `node-pty` and shell spawn. +- `src/workspaces/plugins/Terminal/server/Dockerfile.terminal-sidecar` + - Docker image for sandbox mode. +- `src/workspaces/plugins/Terminal/server/templates/.zshrc` +- `src/workspaces/plugins/Terminal/server/templates/starship-officer.toml` + - Template configs copied into user home for stable shell UX. + +## Client Architecture + +### TerminalView + +`TerminalView` is the reusable component that can be rendered anywhere. + +Props: + +- `className?: string` +- `style?: CSSProperties` +- `wsPath?: string` (default: `/api/terminal/ws`) +- `fontSize?: number` (default: 14) +- `fontFamily?: string` +- `theme?: { background?; foreground?; cursor?; selectionBackground? }` +- `autoFocus?: boolean` (default: true) +- `onReady?: (term: XTerm) => void` +- `onExit?: () => void` +- `onDisconnect?: () => void` + +Behavior: + +- Initializes xterm after mount for StrictMode compatibility. +- Connects to websocket at `wsPath`, appending `token` from localStorage. +- Sends `resize` on open, and on ResizeObserver changes. +- Forwards terminal input as `{ type: 'input', data }` JSON. +- Writes server output on `{ type: 'output', data }`. +- Emits `onExit` when `{ type: 'exit' }` is received. +- Emits `onDisconnect` when the websocket closes. + +### Screen + +`Screen` is a thin wrapper around `TerminalView` for the full-page route: + +``` + + + +``` + +This matches plugin conventions used by FileBrowser. + +## Server Architecture + +### WebSocket Flow + +The terminal websocket is mounted in `src/server.tsx`: + +- Path: `/api/terminal/ws` +- Auth: `token` query parameter, validated with `verify()`. +- Server handler: `terminalWebsocket` from `plugins/Terminal/server`. + +Message formats between client and server: + +- Client -> server: + - `{ type: 'input', data: string }` + - `{ type: 'resize', cols: number, rows: number }` +- Server -> client: + - `{ type: 'output', data: string }` + - `{ type: 'exit' }` + +### Sidecar Model + +The Bun server does not spawn PTYs directly. Instead it proxies to a Node sidecar +that runs `node-pty` for reliability. + +There are two sidecar modes: + +1. Host mode + - Spawns a local Node sidecar on the host via Bun. + - Sidecar binds to `127.0.0.1`. + +2. Docker sandbox mode + - Runs a Docker container per user. + - Container binds to `0.0.0.0` and is mapped to a host port. + - Container runs the same Node sidecar and shell inside the container. + +### Mode Selection + +- Settings are stored in `~/.config/officer.dev/server-settings.json`. +- `terminalSandboxed: true` enables Docker mode. +- If `terminalSandboxed` is falsy, host mode is used. + +### Sidecar Handshake + +On websocket open: + +- Determine user home: `getHomeDir(email)`. +- Ensure the home directory exists. +- Determine mode (host or docker). +- Connect to sidecar on a port (host: default 5337; docker: user-specific). +- Send init payload to sidecar: + +``` +{ + type: 'init', + shell: { command, args, name }, + cwd, + homeDir, + userLabel +} +``` + +- `userLabel` is the email used by the prompt template. +- `cwd` and `homeDir` are `/home/officer` in Docker, or the user home on host. + +### Docker Container Lifecycle + +Containers are named `officer-terminal-${userId}` and stored per user in: + +`data/terminal-containers.json` + +Rules: + +- Reuse an existing container if it exists and is running. +- Restart the container if it exists but stopped. +- Start a new container if none exists; allocate a port starting at 54000. +- Containers are left running for reuse when websocket closes. + +### Docker Image + +- Tag: `officer-terminal-sidecar:v1` +- Built from `Dockerfile.terminal-sidecar` when missing. + +Includes: + +- zsh, git, curl, ca-certificates +- oh-my-zsh +- starship +- fortune-mod, cowsay +- eza +- node, ws, node-pty + +### Shell Configuration + +Templates are copied into the user's home if missing: + +- `.zshrc` +- `.config/starship-officer.toml` + +This provides a stable base for all users while allowing per-user customization. + +Prompt requirement: + +- Format: `email@domain:officer.dev` +- Email colored in button blue (#0891B2) + +## Plugin Conventions + +- `index.ts` exports `plugin` metadata with `id` matching folder name. +- Client exports use `Widget`/`Screen` conventions. Terminal only exports `Screen` + and `TerminalView` (no widget). +- Server exports a websocket handler (not a router). This is a special-case + plugin that is mounted in `src/server.tsx`. + +## Known Constraints + +- Terminal mode is global via server settings; no per-component mode selection. +- `TerminalView` builds the websocket URL from `window.location` and localStorage + token; custom auth is not supported yet. +- Sidecar connection retries are bounded; errors are printed to the terminal. + +## Potential Future Enhancements + +- Allow `TerminalView` to request mode/cwd via websocket init payload. +- Add a read-only mode or initial command support. +- Add per-component theme presets or allow app-level defaults. +- Add better telemetry/logging for sidecar startup failures. + +## API Contract + +This section describes the messages across each layer. + +### Client <-> Server (Bun) + +Client -> server: + +- `input` + - `{ type: 'input', data: string }` + - `data` is raw terminal input from xterm. +- `resize` + - `{ type: 'resize', cols: number, rows: number }` + +Server -> client: + +- `output` + - `{ type: 'output', data: string }` + - `data` is raw PTY output to be written to xterm. +- `exit` + - `{ type: 'exit' }` + - Signals terminal process termination. + +### Server <-> Sidecar (Node) + +Server -> sidecar: + +- `init` + - `{ type: 'init', shell, cwd, homeDir, userLabel }` + - `shell`: `{ command: string; args: string[]; name: string }` + - `cwd`: initial current working directory + - `homeDir`: home directory to set in the sidecar + - `userLabel`: email for the prompt template +- `input` + - `{ type: 'input', data: string }` +- `resize` + - `{ type: 'resize', cols: number, rows: number }` + +Sidecar -> server: + +- `output` + - `{ type: 'output', data: string }` +- `exit` + - `{ type: 'exit' }` + +## Sequence Diagram + +``` +Client (xterm) Bun Server Sidecar (Node) Shell + | | | | + | WS connect | | | + |---------------> | | | + | | connect sidecar | | + | |-------------------> | | + | | init (shell/cwd) | | + | |-------------------> | spawn PTY | + | | |----------------->| + | input | | | + |---------------> | input | | + | |-------------------> | write to PTY | + | output | | | + |<--------------- | output | read from PTY | + | |<------------------- | | + | resize | | | + |---------------> | resize | | + | |-------------------> | set PTY size | +``` + +## Docker Troubleshooting + +Common failure modes and checks: + +- Docker not installed or not in PATH + - `ensureDockerImage()` will throw. Install Docker or fix PATH. + +- Image build fails + - Build uses `Dockerfile.terminal-sidecar` and tag `officer-terminal-sidecar:v1`. + - Check for network access (apt), and that build context is the plugin folder. + +- Container starts but sidecar is unreachable + - Container binds to `0.0.0.0`, host maps `127.0.0.1:${port}:${port}`. + - Port is user-specific, stored in `data/terminal-containers.json`. + - Verify with `docker ps` and `docker logs` for the container name. + +- Permissions inside container + - Container runs with host UID/GID derived from the mounted user home. + - If home dir ownership is incorrect, zsh history may fail to write. + +- Shell missing + - Container must have zsh installed; the Dockerfile includes zsh. + - Host mode resolves shell from `$SHELL` or known paths. + +## Per-User Template Rules + +Templates are copied into user home on first launch only. + +Files: + +- `.zshrc` +- `.config/starship-officer.toml` + +Rules: + +- If a file already exists, it is not overwritten. +- This lets the admin provide a stable default while allowing user customization. +- To reset a user to defaults, delete the per-user files and reconnect.