diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index 9156f577..68730c60 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -1,10 +1,22 @@ import { useState } from 'react'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Play } from 'lucide-react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; import { useServerSettings } from '@/state/useServerSettings'; +import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel'; type VersionInfo = { version: string | null; path: string | null; globalPath: string | null }; type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string }; @@ -106,15 +118,28 @@ export const AIHarnessesSection = () => { setTimeout(() => setCopied(null), 1500); }; - const CopyCommand = ({ command }: { command: string }) => ( + const [, setRunCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + + const [confirmCommand, setConfirmCommand] = useState<{ command: string; refetchKeys: string[] } | null>(null); + + const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
Not globally accessible. Run:
{command} +
+ + !open && setConfirmCommand(null)}> + + + Run with elevated privileges + + You are about to run a command with elevated privileges (sudo). Are you sure? + + + {confirmCommand?.command} + + Cancel + { + if (confirmCommand) setRunCommand(confirmCommand); + setConfirmCommand(null); + }} + > + Run + + + + + ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts new file mode 100644 index 00000000..b3174a66 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts @@ -0,0 +1,6 @@ +export type RunCommandState = { + command: string; + refetchKeys: string[]; +} | null; + +export const RUN_COMMAND_CHANNEL = 'system-settings:run-command'; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 1536155a..9dc63f8a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; -import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings } from 'lucide-react'; +import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -10,8 +10,11 @@ import { Switch } from '@/components/ui/switch'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useQueryClient } from '@tanstack/react-query'; import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { TerminalView } from 'apps/Terminal'; import { appRegistry } from '../Workspaces/app-registry'; import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; import { useSettings } from '@/state/useSettings'; @@ -27,6 +30,7 @@ import { import type { UserSettings } from '@/state/types/user-settings'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { PluginsSection } from './ServerSettings/PluginsSection'; +import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel'; const groups: SettingsSectionGroup[] = [ { @@ -54,7 +58,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({ groups, }); -const layout: LayoutNode = { +const baseLayout: LayoutNode = { type: 'group', id: 'system-root', direction: 'horizontal', @@ -64,11 +68,87 @@ const layout: LayoutNode = { ], }; +const splitLayout: LayoutNode = { + type: 'group', + id: 'system-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'system-left', appType: null }, size: 20 }, + { + node: { + type: 'group', + id: 'system-right-group', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'system-right', appType: null }, size: 50 }, + { node: { type: 'panel', id: 'system-terminal', appType: null }, size: 50 }, + ], + }, + size: 80, + }, + ], +}; + +const SystemTerminalPanel = () => { + const queryClient = useQueryClient(); + const [state, setState] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + const [session, setSession] = useState<{ id: string; command: string } | null>(null); + + useEffect(() => { + if (state && (!session || session.command !== state.command)) { + setSession({ id: `run-cmd-${Date.now()}`, command: state.command }); + } else if (!state) { + setSession(null); + } + }, [state]); + + const close = () => setState(null); + + const onCommandDone = (exitCode: number, output: string) => { + if (state) { + for (const key of state.refetchKeys) { + queryClient.invalidateQueries({ queryKey: [key] }); + } + } + if (exitCode === 0) { + toast.success('Command completed successfully'); + } else { + toast.error(output || `Command failed with exit code ${exitCode}`, { duration: 8000 }); + } + setState(null); + }; + + if (!state || !session) return null; + + return ( +
+
+ Run Command + +
+ +
+ ); +}; + export const SystemSettings = () => { + const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + + const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]); + const panelComponents: PanelComponents = useMemo( () => ({ 'system-left': Sidebar, 'system-right': Content, + 'system-terminal': SystemTerminalPanel, }), [], ); diff --git a/src/servers/api/terminal/pty-sidecar.mjs b/src/servers/api/terminal/pty-sidecar.mjs index d1a206b3..3f587997 100644 --- a/src/servers/api/terminal/pty-sidecar.mjs +++ b/src/servers/api/terminal/pty-sidecar.mjs @@ -3,9 +3,16 @@ import { existsSync } from 'node:fs'; import { cp, mkdir } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; import { WebSocketServer } from 'ws'; import * as pty from 'node-pty'; +const run = (cmd, args, opts = {}) => + new Promise((resolve) => { + const proc = execFile(cmd, args, { stdio: 'ignore', ...opts }, () => resolve()); + proc.on('error', () => resolve()); + }); + const __dirname = dirname(fileURLToPath(import.meta.url)); const isDocker = existsSync('/opt/terminal-templates/.zshrc'); @@ -60,25 +67,14 @@ const ensureUserFiles = async (homeDir) => { if (ohMyZshSource && existsSync(ohMyZshSource)) { await cp(ohMyZshSource, ohMyZshPath, { recursive: true }); } else { - const proc = Bun.spawn({ - cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath], - stdout: 'ignore', - stderr: 'ignore', - }); - await proc.exited; + await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]); } } if (!isDocker) { const starshipBin = join(homeDir, '.local', 'bin', 'starship'); if (!existsSync(starshipBin)) { - const installProc = Bun.spawn({ - cmd: ['sh', '-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], - env: { ...process.env, HOME: homeDir }, - stdout: 'ignore', - stderr: 'ignore', - }); - await installProc.exited; + await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } }); } } }; @@ -149,15 +145,36 @@ wss.on('connection', (ws) => { 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 cols = msg.cols ?? 80; const rows = msg.rows ?? 24; + const isHost = !!msg.host; - try { - await ensureUserFiles(homeDir); - } catch { - // ignore + let ptyEnv; + if (isHost) { + ptyEnv = { ...process.env, TERM: 'xterm-256color' }; + } else { + const prompt = `${userLabel} in %~ %# `; + const bashPrompt = `${userLabel} \\w \\$ `; + + try { + await ensureUserFiles(homeDir); + } catch { + // ignore + } + + ptyEnv = { + ...process.env, + HOME: homeDir, + ZDOTDIR: homeDir, + ZSH: `${homeDir}/.oh-my-zsh`, + SHELL: shell.command, + USER: userLabel, + LOGNAME: userLabel, + OFFICER_TERMINAL_USER: userLabel, + PROMPT: prompt, + PS1: bashPrompt, + TERM: 'xterm-256color', + }; } let term; @@ -167,19 +184,7 @@ wss.on('connection', (ws) => { cols, rows, cwd, - env: { - ...process.env, - HOME: homeDir, - ZDOTDIR: homeDir, - ZSH: `${homeDir}/.oh-my-zsh`, - SHELL: shell.command, - USER: userLabel, - LOGNAME: userLabel, - OFFICER_TERMINAL_USER: userLabel, - PROMPT: prompt, - PS1: bashPrompt, - TERM: 'xterm-256color', - }, + env: ptyEnv, }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start terminal'; @@ -205,7 +210,8 @@ wss.on('connection', (ws) => { } }); - term.onExit(() => { + term.onExit(({ exitCode, signal }) => { + console.log(`[sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`); if (session.ws) { sendJson(session.ws, { type: 'exit' }); } @@ -228,7 +234,11 @@ wss.on('connection', (ws) => { if (msg.cols > 0 && msg.rows > 0) { session.cols = msg.cols; session.rows = msg.rows; - session.term.resize(msg.cols, msg.rows); + try { + session.term.resize(msg.cols, msg.rows); + } catch { + // PTY may have already exited + } } break; case 'cwd': diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index cf6b7eb9..3306ffc9 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -236,20 +236,28 @@ const sidecarAlive = async (port: number): Promise => { } }; -const startHostSidecar = async () => { - if (await sidecarAlive(HOST_SIDECAR_PORT)) { - console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`); - return; +const killSidecarOnPort = (port: number) => { + try { + const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); + if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`); + } catch { + // fuser not available or failed } +}; +const startHostSidecar = async () => { if (hostSidecarProcess) { hostSidecarProcess.kill(); await hostSidecarProcess.exited.catch(() => {}); hostSidecarProcess = null; } + + killSidecarOnPort(HOST_SIDECAR_PORT); + await new Promise((resolve) => setTimeout(resolve, 200)); + const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url)); hostSidecarProcess = Bun.spawn({ - cmd: ['bun', sidecarPath], + cmd: ['node', sidecarPath], env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) }, stdout: 'inherit', stderr: 'inherit', @@ -323,6 +331,7 @@ export const terminalWebsocket = { sidecar.send( JSON.stringify({ type: 'init', + host: true, sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, cwd: resolveCwd(process.env.HOME!, ws.data.cwd), diff --git a/src/workspaces/apps/Terminal/Terminal.tsx b/src/workspaces/apps/Terminal/Terminal.tsx index a04ae801..21003171 100644 --- a/src/workspaces/apps/Terminal/Terminal.tsx +++ b/src/workspaces/apps/Terminal/Terminal.tsx @@ -19,12 +19,14 @@ export type TerminalViewProps = { sessionId?: string; sandboxed?: boolean; cwd?: string; + command?: string; fontSize?: number; fontFamily?: string; theme?: TerminalTheme; autoFocus?: boolean; onReady?: (term: XTerm) => void; onExit?: () => void; + onCommandDone?: (exitCode: number, output: string) => void; onDisconnect?: () => void; }; @@ -53,12 +55,14 @@ export const TerminalView = ({ sessionId, sandboxed = true, cwd, + command, fontSize = 14, fontFamily = 'Menlo, Monaco, "Courier New", monospace', theme, autoFocus = true, onReady, onExit, + onCommandDone, onDisconnect, }: TerminalViewProps) => { const containerRef = useRef(null); @@ -68,11 +72,15 @@ export const TerminalView = ({ const isMounted = useMounted(); const onReadyRef = useRef(onReady); const onExitRef = useRef(onExit); + const onCommandDoneRef = useRef(onCommandDone); const onDisconnectRef = useRef(onDisconnect); + const commandRef = useRef(command); onReadyRef.current = onReady; onExitRef.current = onExit; + onCommandDoneRef.current = onCommandDone; onDisconnectRef.current = onDisconnect; + commandRef.current = command; const background = theme?.background ?? DEFAULT_THEME.background; const foreground = theme?.foreground ?? DEFAULT_THEME.foreground; @@ -118,6 +126,12 @@ export const TerminalView = ({ const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd)); wsRef.current = ws; + let commandSent = false; + let commandDone = false; + let commandOutput = ''; + const EXIT_MARKER = '__OFFICER_EXIT_'; + // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); const handleOpen = () => { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); @@ -128,6 +142,30 @@ export const TerminalView = ({ const msg = JSON.parse(ev.data as string); if (msg.type === 'output') { term.write(msg.data); + if (commandRef.current && !commandSent) { + commandSent = true; + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + const wrapped = onCommandDoneRef.current + ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` + : commandRef.current; + ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' })); + } + }, 100); + } + if (commandSent && !commandDone && onCommandDoneRef.current) { + commandOutput += msg.data as string; + const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/); + if (markerMatch) { + const exitCode = Number(markerMatch[1]); + const raw = stripAnsi(commandOutput).slice(0, markerMatch.index); + const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20))); + const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim(); + commandDone = true; + onCommandDoneRef.current(exitCode, output); + } + } } else if (msg.type === 'exit') { term.write('\r\n[Process exited]\r\n'); onExitRef.current?.();