diff --git a/README.md b/README.md index 89058140..a14d260a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ needs to be useful: | πŸ₯· **Bot-detection hardening** | Sites that block automated browsers see a normal Chrome: the automation flag stays off, and even a headless browser has the user agent and screen of a regular Chrome window. *Run check* (Settings β†’ Browser) shows what bot detection sees, signal by signal. | | πŸ–₯️ **Computer use** | Share a single window, a display, the entire desktop (every monitor) or a browser tab with an agent β€” like sharing your screen with ChatGPT. A shared window is controlled **in the background** with [Cua Driver](https://github.com/trycua/cua): your mouse and keyboard stay yours. Watch live and take over anytime. | | πŸ’» **macOS VMs** | Give an agent its own Mac: spin up isolated macOS virtual machines (Apple's Virtualization framework, via [Tart](https://tart.run)) with one click and assign them to an agent, a chat or a workspace. The agent works *entirely inside the VM* β€” commands, files, apps (computer use with Cua Driver) and the web (Google Chrome with browser-use) β€” and no browser opens on your Mac. Watch the VM's screen next to the chat and take control anytime. Allow it once and agents sign in inside the VM too: Godmode fills your saved logins and 2FA codes for them (best effort β€” the agent controls the VM, so it's closer to reveal than to fill-only). VMs live on your Mac, keep everything between tasks, suspend when you quit, and can be reset to a clean macOS or duplicated in seconds. | +| πŸ”Œ **SSH servers** | Let agents work on your servers: save a server with a password or an SSH key (paste it, pick one from `~/.ssh` or generate a new one) and give it to a chat or an agent. Godmode signs in and answers `sudo` β€” the AI never sees the password or the key. Agents run commands, edit config files and copy files back and forth; the host key is pinned on the first connection. | | πŸͺ **Chrome session import** | Continue where Chrome left off β€” import cookies from your Chrome/Edge/Brave profile (profile-use technique), or sync via browser-use `profile-use`. | | πŸ” **Vault** | Logins with password generator, per-workspace or global, AES-256-GCM encrypted, fully audited. | | πŸ“₯ **Password import** | Bring logins over from Chrome (and Edge, Brave, Arc), 1Password (.1pux or CSV), Bitwarden, Apple Passwords, Firefox and more β€” with a preview that updates saved logins instead of duplicating them. | @@ -140,6 +141,10 @@ needs to be useful: API tools with their keys, scopes and addresses
Tools β€” APIs agents use with your keys, scoped globally, per workspace or per agent Add Nano Banana: name, what it's for and the API key
Add a tool β€” presets bring the address and docs, you add the key + + SSH servers with their connection status, pinned host keys and the agents and chats using them
SSH servers β€” password or key, sealed in the vault; host keys pinned on the first connection + An agent reading a config and a log on a server over SSH and changing the config after backing it up
Work on servers β€” pick servers for a chat; the agent runs commands and edits files there + ## πŸ“¦ Install @@ -236,6 +241,20 @@ workspace's, and boots it when needed. You can also just ask Godmode: *"Give the Needs a Mac with Apple silicon. macOS allows **two** macOS VMs to run at the same time; Godmode tells you which one to stop when a third is needed. +### SSH servers β€” let agents work on your servers + +Open **SSH servers** in the sidebar and click **Add server**: host, port, user and either a **password** or an **SSH +key** β€” paste it, load a file, pick one from `~/.ssh` on this computer, or **generate** a new one and add its public key +to the server's `~/.ssh/authorized_keys`. **Test connection** signs in once and shows the server's host key; Godmode +trusts only that key from then on. Then pick the server for a **chat** (the *SSH* chip in the message box) or an +**agent** (agent settings β†’ *SSH servers*, used in every run). A run gets its chat's servers and its agent's. + +| | | +|---|---| +| **What the agent gets** | An `ssh` tool set: `shell` (a command in the user's shell; `sudo: true` runs it as root and Godmode types the saved password into sudo's prompt), `read_file` / `write_file` / `edit_file` (SFTP, or the shell when a server has none), and `upload` / `download` between the chat's folders on your computer and the server. | +| **Secrets** | The password, key and passphrase are sealed in the vault and never part of the prompt; transcripts and tool results mask them, also when a command prints them. The agent works in that account's shell, so give it an account with only the rights it needs β€” a narrow `NOPASSWD` sudo rule is safer than a saved sudo password. | +| **Checking in** | Each server card shows whether it was reachable, its OS and pinned host key, which agents and chats use it, and a **Run command** box for a quick look yourself. | + ### Good to know - **macOS β€” Chrome session import** reads your Chrome profile, which macOS protects: grant Godmode @@ -338,6 +357,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). - [x] Automations: schedules, app events (Composio triggers), plain-language conditions and webhooks - [x] Agents working in a dedicated macOS VM (Tart / Virtualization.framework): shell, files and screen, assigned per agent, chat or workspace - [x] API tools: any API with a key (Nano Banana, OpenAI, ElevenLabs…) for agents, global / workspace / agent +- [x] SSH servers: agents run commands, edit files and copy files on remote machines β€” password or key, sudo, pinned host keys - [ ] Windows / Linux VMs - [ ] Mobile companion app & push notifications - [ ] Team mode: shared workspaces and approvals diff --git a/SECURITY.md b/SECURITY.md index 69fd8a38..59ef6dab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,6 +43,16 @@ We aim to acknowledge reports within 72 hours and to ship a fix for critical iss Godmode's own windows and dashboard tabs can't be shared. Unattended desktop access for routines is a human-only agent setting; agents without it can't hand work to agents that have it, and backups never restore it. Shares and their first use per run are audited (`computer.share`, `computer.unshare`, `computer.control`). +- **SSH servers are assigned by you**: an agent only reaches the servers you give its chat or the agent itself β€” agents + can't assign servers to themselves or others, and delegated work doesn't inherit a chat's servers. Godmode signs in + with the password or key sealed in the vault; they are never part of the prompt, and tool results mask them (the + password, the passphrase and every line of the key). The server's host key is pinned on the first connection and a + different key is refused. Uploads and downloads only use the folders of the run on your computer and never write + into `.git` or `.claude` folders. First use per run and every sudo password entry are audited (`ssh.use`, `ssh.sudo`). +- **What an agent can do on a server**: whatever that account may do. It runs commands in the account's own shell, so + a determined (or prompt-injected) agent can change what runs when Godmode enters the sudo password there and capture + it β€” saving a sudo password is best effort, like typing logins into a VM. Give agents an account with only the rights + the work needs, and prefer narrow `NOPASSWD` sudo rules to a saved sudo password. ## Important caveats diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8419ecb0..0f864a90 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -31,6 +31,7 @@ const MessagingPage = lazy(() => import("@/pages/messaging/messaging-page")); const BrowserPage = lazy(() => import("@/pages/browser/browser-page")); const ComputerPage = lazy(() => import("@/pages/computer/computer-page")); const VmsPage = lazy(() => import("@/pages/vms/vms-page")); +const SshPage = lazy(() => import("@/pages/ssh/ssh-page")); const InboxPage = lazy(() => import("@/pages/inbox/inbox-page")); const SettingsPage = lazy(() => import("@/pages/settings/settings-page")); @@ -116,6 +117,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/agents/agent-form.tsx b/apps/desktop/src/components/agents/agent-form.tsx index c7c52514..8e87fa20 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useLocation } from "react-router"; import { motion, AnimatePresence } from "motion/react"; import { + ArrowRight, Bot, Box, BrainCircuit, @@ -13,6 +14,7 @@ import { MonitorUp, Plug, Plus, + Server, ShieldCheck, Sparkles, Trash2, @@ -25,13 +27,14 @@ import type { Agent, AgentInput, Effort, SecretAccessMode, SubagentDefinition } import { DEFAULT_MODEL, EFFORT_LABELS, EFFORT_OPTIONS, effortForModel, findModel } from "@godmode/shared"; import { api } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; -import { useAllAgents, useBootstrap, useModelCatalog, useVmChoices, useWorkspaces } from "@/lib/hooks"; +import { useAllAgents, useBootstrap, useModelCatalog, useSshServers, useVmChoices, useWorkspaces } from "@/lib/hooks"; import { isMac, modKey } from "@/lib/desktop"; import { useUi } from "@/stores/ui"; import { useDraft } from "@/lib/drafts"; import { cn } from "@/lib/utils"; import { AgentAvatar, DraftStatus, Kbd, Section } from "@/components/common"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -52,6 +55,7 @@ import { useApiTools } from "@/components/integrations/api-tools-tab"; import { ApiToolDialog, type ApiToolDialogState } from "@/components/integrations/api-tool-dialog"; import { toolIcon } from "@/components/integrations/api-tool-presets"; import { ScopeChip } from "@/components/integrations/scope-picker"; +import { SSH_STATUS_LABEL, SshStatusDot, sshAddress, sshStatus } from "@/components/ssh/ssh-parts"; export interface AgentFormValues { name: string; @@ -80,6 +84,7 @@ export interface AgentFormValues { workingDirectory: string | null; /** macOS VM the agent works in; null = its workspace's (if any). */ vmId: string | null; + sshServerIds: string[]; } /** Seed values for the form from an existing agent, a template, or nothing. */ @@ -111,6 +116,7 @@ export function agentToValues( subagents: source?.subagents ?? [], workingDirectory: source?.workingDirectory ?? null, vmId: source?.vmId ?? null, + sshServerIds: source?.sshServerIds ?? [], }; } @@ -141,6 +147,7 @@ export function valuesToInput(v: AgentFormValues): AgentInput { .filter((s) => s.name), workingDirectory: v.workingDirectory, vmId: v.vmId, + sshServerIds: v.sshServerIds, }; } @@ -179,6 +186,7 @@ const SECTIONS = [ { id: "browser", label: "Browser" }, { id: "computer", label: "Computer" }, { id: "vm", label: "Virtual machine" }, + { id: "ssh", label: "SSH servers" }, { id: "tools", label: "Tools" }, { id: "subagents", label: "Subagents" }, ]; @@ -585,6 +593,10 @@ export function AgentForm({ )} + + set("sshServerIds", v)} /> + +
{vmChoices.vms.find((v) => v.id === values.vmId)?.name ?? "VM"} )} + {values.sshServerIds.length > 0 && ( + + + {values.sshServerIds.length === 1 ? "1 server" : `${values.sshServerIds.length} servers`} + + )} {values.workingDirectory && ( @@ -862,6 +880,57 @@ function VmField({ value, workspaceId, onChange }: { value: string | null; works ); } +function SshField({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) { + const { data: servers = [], isLoading } = useSshServers(); + if (!isLoading && servers.length === 0) { + return ( +
+ +

No SSH servers yet β€” add one and this agent can work on it.

+ +
+ ); + } + const toggle = (id: string, on: boolean) => onChange(on ? [...value.filter((x) => x !== id), id] : value.filter((x) => x !== id)); + return ( +
+
+ + Can sign in to + + + Manage servers + +
+
+ {isLoading + ? [0, 1].map((i) =>
) + : servers.map((s) => { + const id = `agent-ssh-${s.id}`; + return ( + + ); + })} +
+

Every run of this agent can sign in to these. A single chat can add more with the SSH button in its message box.

+
+ ); +} + function WorkspaceField({ value, onChange, disabled }: { value: string | null; onChange: (v: string | null) => void; disabled?: boolean }) { const { data: workspaces = [] } = useWorkspaces(); return ( diff --git a/apps/desktop/src/components/chat/tool-meta.ts b/apps/desktop/src/components/chat/tool-meta.ts index 0b978a61..f11d0c48 100644 --- a/apps/desktop/src/components/chat/tool-meta.ts +++ b/apps/desktop/src/components/chat/tool-meta.ts @@ -10,10 +10,12 @@ import { Camera, Code2, Eye, + FileDown, FilePen, FilePlus2, FileSearch, FileText, + FileUp, FolderSearch, Globe, History, @@ -35,6 +37,7 @@ import { ScanText, ScrollText, Search, + Server, ShieldAlert, ShieldCheck, Terminal, @@ -227,6 +230,46 @@ function vmMeta(tool: string, input: Input): Omit { } } +/** "the server" unless the input names it (a name, not an `ssh_…` id). */ +function sshTarget(input: Input): string { + const server = input.server; + return typeof server === "string" && server.trim() && !server.startsWith("ssh_") ? server.trim() : "the server"; +} + +/** The `ssh` server: commands, files and transfers on the user's SSH servers. */ +function sshMeta(tool: string, input: Input): Omit { + const on = sshTarget(input); + const path = str(input.path ?? input.remotePath ?? input.remote_path); + const local = str(input.localPath ?? input.local_path); + switch (tool) { + case "shell": + return { + kind: "shell", + icon: Terminal, + title: `Ran a command on ${on}${input.sudo === true ? " as root" : ""}`, + detail: truncate(str(input.command), 120) || undefined, + }; + case "read_file": + return { kind: "file", icon: FileText, title: path ? `Read ${basename(path)} on ${on}` : `Read a file on ${on}`, detail: path || undefined }; + case "write_file": + return { kind: "file", icon: FilePlus2, title: path ? `Wrote ${basename(path)} on ${on}` : `Wrote a file on ${on}`, detail: path || undefined }; + case "edit_file": + return { kind: "file", icon: FilePen, title: path ? `Edited ${basename(path)} on ${on}` : `Edited a file on ${on}`, detail: path || undefined }; + case "upload": { + const name = basename(local || path); + return { kind: "file", icon: FileUp, title: name ? `Uploaded ${name} to ${on}` : `Uploaded a file to ${on}`, detail: path || undefined }; + } + case "download": { + const name = basename(path || local); + return { kind: "file", icon: FileDown, title: name ? `Downloaded ${name} from ${on}` : `Downloaded a file from ${on}`, detail: local || undefined }; + } + case "list_servers": + return { kind: "other", icon: Server, title: "Checked SSH servers" }; + default: + return { kind: "other", icon: Server, title: humanize(tool), detail: on === "the server" ? undefined : on }; + } +} + /** The `cua` server: Cua Driver controlling the apps and windows inside the VM. */ function cuaMeta(tool: string, input: Input): Omit { const app = str(input.app_name ?? input.app ?? input.name ?? input.bundle_id); @@ -427,6 +470,7 @@ export function describeTool(name: string, rawInput: unknown, ctx: ToolContext = return { ...computerMeta(tool, input), kind: "computer", server, tool }; } if (server === "vm") return { ...vmMeta(tool, input), server, tool }; + if (server === "ssh") return { ...sshMeta(tool, input), server, tool }; if (server === "cua") return { ...cuaMeta(tool, input), kind: "computer", server, tool }; if (server === "godmode" || (server === null && GODMODE_TOOLS.has(tool)) || GODMODE_TOOLS.has(tool)) { const m = godmodeMeta(tool, input, ctx); diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index 9c49f3b9..8403c5aa 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -17,6 +17,7 @@ import { PanelLeft, Plug, Search, + Server, Settings, ShieldCheck, SquareKanban, @@ -116,6 +117,7 @@ export function AppShell({ children }: { children: ReactNode }) { { to: "/browser", label: "Browser", icon: }, { to: "/computer", label: "Computer", icon: }, { to: "/vms", label: "Virtual machines", icon: }, + { to: "/ssh", label: "SSH servers", icon: }, ]; return ( diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx index c143693b..bd5641b0 100644 --- a/apps/desktop/src/components/layout/command-palette.tsx +++ b/apps/desktop/src/components/layout/command-palette.tsx @@ -17,6 +17,7 @@ import { Plug, Plus, ScrollText, + Server, Settings, ShieldCheck, Sun, @@ -133,6 +134,9 @@ export function CommandPalette() { go("/vms")}> Virtual machines + go("/ssh")}> + SSH servers + go("/workspaces")}> Workspaces diff --git a/apps/desktop/src/components/ssh/server-card.tsx b/apps/desktop/src/components/ssh/server-card.tsx new file mode 100644 index 00000000..3e887f55 --- /dev/null +++ b/apps/desktop/src/components/ssh/server-card.tsx @@ -0,0 +1,403 @@ +import { useState, type FormEvent } from "react"; +import { Link } from "react-router"; +import { useMutation } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { + Bot, + Check, + ChevronDown, + Copy, + Ellipsis, + KeyRound, + MessageSquare, + Monitor, + Pencil, + Play, + Plus, + RectangleEllipsis, + RefreshCw, + Server, + ShieldCheck, + ShieldOff, + Terminal, + Trash2, + TriangleAlert, + X, +} from "lucide-react"; +import { sshCommand, type SshAssignment, type SshExecResult, type SshServer } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { AgentAvatar } from "@/components/common"; +import { LiveDot } from "@/components/aicss/Motion"; +import { CopyButton } from "@/components/chat/copy-button"; +import { api, errorMessage } from "@/lib/api"; +import { useAllAgents } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; +import { SshStatusBadge, keyTypeLabel, sshAddress, sshStatus } from "./ssh-parts"; +import type { SshActions } from "./use-ssh-actions"; + +export function ServerCard({ server, actions, onEdit, onDelete }: { server: SshServer; actions: SshActions; onEdit: () => void; onDelete: () => void }) { + const [commandOpen, setCommandOpen] = useState(false); + const status = sshStatus(server); + const testing = actions.testing.has(server.id); + const osName = server.os?.split(" Β· ")[0]; + + return ( +
+
+
+
+ +
+ + {status === "connected" ? ( + + ) : ( + + )} + +
+ +
+
+

{server.name}

+ +
+

+ + {sshAddress(server)} + + +

+

+ {server.auth === "key" ? ( + + SSH key{server.key ? ` Β· ${keyTypeLabel(server.key.type)}` : ""} + {server.hasPassword && " Β· sudo password saved"} + + ) : ( + + Password + + )} + {osName && ( + + {osName} + + )} +

+ {server.hostKey && } +
+ + +
+ + {server.description &&

{server.description}

} + + {server.lastError && !testing && ( +
+ +

{server.lastError}

+ +
+ )} + +
+ +
+ +
+ + + + + {server.hostKey ? "Sign in and check the host key" : "Sign in and pin the server's host key"} + + +
+ + + {commandOpen && ( + + + + )} + +
+ ); +} + +function HostKeyLine({ server }: { server: SshServer }) { + const key = server.hostKey!; + return ( + + +

+ + Pinned host key: + {keyTypeLabel(key.type)} + {key.fingerprint} +

+
+ + + {key.type} {key.fingerprint} + + Pinned host key β€” Godmode refuses the server if it presents another one. + +
+ ); +} + +function ServerMenu({ server, actions, onEdit, onDelete }: { server: SshServer; actions: SshActions; onEdit: () => void; onDelete: () => void }) { + const pending = + (actions.forgetHostKey.isPending && actions.forgetHostKey.variables?.id === server.id) || (actions.remove.isPending && actions.remove.variables?.id === server.id); + return ( + + + + + + + Edit… + + {server.auth === "key" && server.key && ( + void actions.copyPublicKey(server)}> + Copy public key + + )} + {server.hostKey && ( + actions.forgetHostKey.mutate(server)}> + Forget host key + + )} + + + Delete… + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Used by */ +/* ------------------------------------------------------------------ */ + +function assignmentHref(a: SshAssignment): string { + return a.kind === "agent" ? `/agents/${a.id}/settings#ssh` : `/chat/${a.id}`; +} + +function UsedBy({ server, actions }: { server: SshServer; actions: SshActions }) { + const { data: agents = [] } = useAllAgents(); + const removing = (a: SshAssignment) => + actions.assign.isPending && actions.assign.variables?.server.id === server.id && actions.assign.variables.id === a.id && !actions.assign.variables.assigned; + + return ( +
+ Used by + {server.assignments.length === 0 && No one yet} + {server.assignments.map((a) => { + const agent = a.kind === "agent" ? agents.find((x) => x.id === a.id) : undefined; + const kind = a.kind === "agent" ? "Agent" : "Chat"; + const label = a.name || (a.kind === "conversation" ? "Untitled chat" : kind); + return ( + + + {agent ? ( + + ) : a.kind === "agent" ? ( + + ) : ( + + )} + {label} + + + + ); + })} + +
+ ); +} + +function AssignPopover({ server, actions }: { server: SshServer; actions: SshActions }) { + const { data: agents = [] } = useAllAgents(); + const sorted = [...agents].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)); + const assigned = new Set(server.assignments.filter((a) => a.kind === "agent").map((a) => a.id)); + const pendingId = actions.assign.isPending && actions.assign.variables?.server.id === server.id ? actions.assign.variables.id : null; + + return ( + + + + + + + {sorted.length > 6 && } + + No agents found. + + {sorted.map((a) => { + const on = assigned.has(a.id); + return ( + actions.assign.mutate({ server, kind: "agent", id: a.id, name: a.name, assigned: !on })} + className="gap-2.5 py-1.5" + > + + {a.name} + {pendingId === a.id ? : on && } + + ); + })} + + + +

+ For a single chat, pick the server with the SSH button in its message box. +

+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Run a command */ +/* ------------------------------------------------------------------ */ + +function CommandPanel({ server }: { server: SshServer }) { + const [command, setCommand] = useState(""); + const exec = useMutation({ + mutationFn: (cmd: string) => api.ssh.exec(server.id, { command: cmd, timeoutSeconds: 120 }), + }); + const result: SshExecResult | undefined = exec.data; + + const submit = (e: FormEvent) => { + e.preventDefault(); + const cmd = command.trim(); + if (cmd && !exec.isPending) exec.mutate(cmd); + }; + + return ( +
+
+
+ + $ + + setCommand(e.target.value)} + placeholder="uptime && df -h /" + aria-label={`Command to run on ${server.name}`} + autoComplete="off" + spellCheck={false} + className="h-8 pl-7 font-mono text-[12.5px]" + /> +
+ +
+

+ Runs as {server.username} on {server.host} β€” stops after 2 minutes. Agents use the same + connection. +

+ {exec.isError && ( +

+ {errorMessage(exec.error)} +

+ )} + {result && ( +
+
+ + exit {result.exitCode ?? "–"} + + {result.timedOut && Timed out} + {result.durationMs < 1000 ? `${result.durationMs} ms` : `${(result.durationMs / 1000).toFixed(1)} s`} + {exec.variables} +
+
+            {result.stdout}
+            {result.stderr && {result.stderr}}
+            {!result.stdout && !result.stderr && (no output)}
+          
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/ssh/server-dialog.tsx b/apps/desktop/src/components/ssh/server-dialog.tsx new file mode 100644 index 00000000..e1be3e8a --- /dev/null +++ b/apps/desktop/src/components/ssh/server-dialog.tsx @@ -0,0 +1,800 @@ +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { ChevronDown, CircleCheck, FileUp, KeyRound, Laptop, RectangleEllipsis, ShieldAlert, ShieldCheck, Trash2, TriangleAlert, X } from "lucide-react"; +import { toast } from "sonner"; +import type { SshAuthMethod, SshGeneratedKey, SshHostKey, SshLocalKey, SshServer, SshServerInput, SshServerPatch, SshTestResult, SshTestStage } from "@godmode/shared"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { InputGroupButton } from "@/components/ui/input-group"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { CopyButton } from "@/components/chat/copy-button"; +import { useShortPath } from "@/components/chat/folder-picker"; +import { PasswordInput } from "@/components/vault/password-input"; +import { toastApiError } from "@/components/vault/vault-utils"; +import { api, errorMessage } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { keyTypeLabel, parseSshTarget, shortFingerprint } from "./ssh-parts"; +import type { SshActions } from "./use-ssh-actions"; + +const MAX_NAME = 60; +const MAX_KEY_FILE = 64 * 1024; + +type KeySource = { kind: "paste" } | { kind: "local"; key: SshLocalKey } | { kind: "generated"; key: SshGeneratedKey }; +type TestState = { sig: string; result: SshTestResult } | { sig: string; error: string }; + +const TOGGLE_ON = "data-[state=on]:bg-secondary data-[state=on]:text-foreground data-[state=on]:ring-1 data-[state=on]:ring-foreground/15 data-[state=on]:ring-inset"; + +const STAGE_TITLE: Record = { + config: "Check the settings", + connect: "Couldn't reach the server", + "host-key": "The host key check failed", + auth: "Sign-in failed", + command: "Signed in, but the first command failed", +}; + +function looksLikePublicKey(text: string): boolean { + const t = text.trim(); + return /^(ssh-|ecdsa-|sk-)\S+\s+AAAA/.test(t) || /BEGIN (SSH2 )?PUBLIC KEY/.test(t); +} + +function Field({ label, htmlFor, hint, error, children, className }: { label: ReactNode; htmlFor: string; hint?: ReactNode; error?: string; children: ReactNode; className?: string }) { + return ( +
+ + {children} + {error ? ( +

+ {error} +

+ ) : ( + hint &&

{hint}

+ )} +
+ ); +} + +function Optional() { + return (optional); +} + +/** Add or edit an SSH server: address, sign-in (password or key), a connection test that pins the host key. */ +export function ServerDialog({ + open, + server, + onOpenChange, + actions, +}: { + open: boolean; + /** null = add a new server. */ + server: SshServer | null; + onOpenChange: (open: boolean) => void; + actions: SshActions; +}) { + const qc = useQueryClient(); + const short = useShortPath(); + const fileRef = useRef(null); + const bodyRef = useRef(null); + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("22"); + const [username, setUsername] = useState(""); + const [auth, setAuth] = useState("password"); + const [password, setPassword] = useState(""); + const [privateKey, setPrivateKey] = useState(""); + const [fileName, setFileName] = useState(null); + const [keySource, setKeySource] = useState({ kind: "paste" }); + const [replacingKey, setReplacingKey] = useState(false); + const [passphrase, setPassphrase] = useState(""); + const [sudoPassword, setSudoPassword] = useState(""); + const [removeSudo, setRemoveSudo] = useState(false); + const [description, setDescription] = useState(""); + const [trustNewKey, setTrustNewKey] = useState(false); + const [testState, setTestState] = useState(null); + const [showErrors, setShowErrors] = useState(false); + + useEffect(() => { + if (!open) return; + setName(server?.name ?? ""); + setHost(server?.host ?? ""); + setPort(String(server?.port ?? 22)); + setUsername(server?.username ?? ""); + setAuth(server?.auth ?? "password"); + setPassword(""); + setPrivateKey(""); + setFileName(null); + setKeySource({ kind: "paste" }); + setReplacingKey(false); + setPassphrase(""); + setSudoPassword(""); + setRemoveSudo(false); + setDescription(server?.description ?? ""); + setTrustNewKey(false); + setTestState(null); + setShowErrors(false); + // Only when the dialog opens: realtime list updates must not reset what the user typed. + }, [open, server?.id]); + + const localKeys = useQuery({ + queryKey: qk.sshLocalKeys, + queryFn: api.ssh.localKeys, + enabled: open && auth === "key", + staleTime: 30_000, + retry: false, + }); + + const editing = !!server; + const savedKey = server?.key && !replacingKey ? server.key : null; + const savedLoginPassword = !!server?.hasPassword; + const portNum = Number(port); + const newKeyGiven = keySource.kind !== "paste" || privateKey.trim() !== ""; + const publicKeyPasted = keySource.kind === "paste" && looksLikePublicKey(privateKey); + + const secrets = (): Partial => { + const out: Partial = {}; + if (auth === "password") { + if (password) out.password = password; + return out; + } + if (!savedKey) { + if (keySource.kind === "local") out.privateKeyPath = keySource.key.path; + else out.privateKey = keySource.kind === "generated" ? keySource.key.privateKey : privateKey; + if (passphrase || server?.key) out.passphrase = passphrase; + } else if (passphrase) out.passphrase = passphrase; + if (sudoPassword) out.password = sudoPassword; + else if (removeSudo && savedLoginPassword) out.password = ""; + return out; + }; + + const errors: Partial> = {}; + if (!name.trim()) errors.name = "Give the server a name"; + if (!host.trim()) errors.host = "Enter a host name or IP address"; + else if (/\s|@/.test(host.trim())) errors.host = "Only the host β€” the user goes below"; + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) errors.port = "1–65535"; + if (!username.trim()) errors.username = "Enter the user to sign in as"; + if (auth === "password" && !password && !(editing && savedLoginPassword)) errors.password = "Enter the password"; + if (auth === "key" && !savedKey) { + if (publicKeyPasted) errors.key = "That's a public key β€” use the private key (the file without .pub)"; + else if (!newKeyGiven) errors.key = "Paste, load or generate a private key"; + } + const connectionErrors = { ...errors }; + delete connectionErrors.name; + const valid = Object.keys(errors).length === 0; + const err = (k: keyof typeof errors) => (showErrors ? errors[k] : undefined); + + const sig = JSON.stringify([host.trim(), portNum, username.trim(), auth, secrets()]); + const tested = testState?.sig === sig ? testState : null; + const result = tested && "result" in tested ? tested.result : null; + const seenKey = result?.hostKey ?? null; + const pinKey: SshHostKey | null = seenKey && ((result?.ok && !result.hostKeyChanged) || (result?.hostKeyChanged && trustNewKey)) ? seenKey : null; + + const baseInput = (): SshServerInput => ({ + name: name.trim() || host.trim(), + host: host.trim(), + port: portNum, + username: username.trim(), + auth, + description: description.trim(), + ...secrets(), + }); + + const test = useMutation({ + mutationFn: ({ input }: { input: SshServerInput; sig: string }) => api.ssh.try({ ...input, id: server?.id }), + onSuccess: (res, { sig: s }) => { + setTrustNewKey(false); + setTestState({ sig: s, result: res }); + }, + onError: (e, { sig: s }) => setTestState({ sig: s, error: errorMessage(e) }), + }); + const revealResult = () => bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight, behavior: "smooth" }); + + const runTest = () => { + if (Object.keys(connectionErrors).length > 0) { + setShowErrors(true); + return; + } + const input = baseInput(); + if (trustNewKey && seenKey) input.hostKey = seenKey; + test.mutate({ input, sig }); + }; + + const generate = useMutation({ + mutationFn: () => api.ssh.generateKey(`godmode-${(name.trim() || host.trim() || "server").toLowerCase().replace(/[^a-z0-9.-]+/g, "-")}`), + onSuccess: (key) => { + setKeySource({ kind: "generated", key }); + setPrivateKey(""); + setFileName(null); + setPassphrase(""); + }, + onError: (e) => toastApiError(e, "Couldn't generate a key", qc), + }); + + const create = useMutation({ + mutationFn: (input: SshServerInput) => api.ssh.create(input), + onSuccess: (created) => { + actions.put(created); + toast.success("Server added", { description: "Pick it for a chat with the SSH button, or assign it to an agent." }); + actions.test.mutate({ server: created, silent: true }); + onOpenChange(false); + }, + onError: (e) => toastApiError(e, "Couldn't add the server", qc), + }); + + const patch: SshServerPatch = {}; + if (server) { + const input = baseInput(); + if (input.name !== server.name) patch.name = input.name; + if (input.host !== server.host) patch.host = input.host; + if (input.port !== server.port) patch.port = input.port; + if (input.username !== server.username) patch.username = input.username; + if (input.auth !== server.auth) patch.auth = input.auth; + if (input.description !== server.description) patch.description = input.description; + Object.assign(patch, secrets()); + if (pinKey && (pinKey.fingerprint !== server.hostKey?.fingerprint || pinKey.type !== server.hostKey?.type)) patch.hostKey = pinKey; + } + const changed = Object.keys(patch).length > 0; + const reconnects = ["host", "port", "username", "auth", "password", "privateKey", "privateKeyPath", "passphrase", "hostKey"].some((k) => k in patch); + + const update = useMutation({ + mutationFn: ({ id, patch: p }: { id: string; patch: SshServerPatch }) => api.ssh.update(id, p), + onSuccess: (next) => { + actions.put(next); + toast.success("Changes saved"); + if (reconnects) actions.test.mutate({ server: next, silent: true }); + onOpenChange(false); + }, + onError: (e) => toastApiError(e, "Couldn't save the changes", qc), + }); + + const saving = create.isPending || update.isPending; + const submit = (e: FormEvent) => { + e.preventDefault(); + if (!valid) { + setShowErrors(true); + const first = (["name", "host", "port", "username", "password", "key"] as const).find((k) => errors[k]); + const target = { name: "ssh-name", host: "ssh-host", port: "ssh-port", username: "ssh-user", password: "ssh-password", key: "ssh-key" }[first ?? "name"]; + document.getElementById(target)?.focus(); + return; + } + if (saving) return; + if (server) { + if (changed) update.mutate({ id: server.id, patch }); + else onOpenChange(false); + } else create.mutate({ ...baseInput(), ...(pinKey ? { hostKey: pinKey } : {}) }); + }; + + const onHostInput = (text: string) => { + const parsed = parseSshTarget(text); + if (!parsed) return false; + setHost(parsed.host); + if (parsed.username) setUsername(parsed.username); + if (parsed.port) setPort(String(parsed.port)); + return true; + }; + + const loadFile = async (file: File | undefined) => { + if (fileRef.current) fileRef.current.value = ""; + if (!file) return; + if (file.size > MAX_KEY_FILE) { + toast.error("That file is too big for a private key"); + return; + } + const text = await file.text(); + setKeySource({ kind: "paste" }); + setPrivateKey(text); + setFileName(file.name); + }; + + const clearKey = () => { + setKeySource({ kind: "paste" }); + setPrivateKey(""); + setFileName(null); + setPassphrase(""); + }; + + const keys = localKeys.data ?? []; + const localEncrypted = keySource.kind === "local" && keySource.key.encrypted; + + return ( + + +
+ + {server ? `Edit β€œ${server.name}”` : "Add SSH server"} + Godmode signs in for your agents. The password or key is sealed in the vault β€” the AI never sees it. + + +
+ + setName(e.target.value)} + maxLength={MAX_NAME} + autoFocus + placeholder="e.g. Production web" + aria-invalid={!!err("name")} + aria-describedby={err("name") ? "ssh-name-error" : undefined} + /> + + +
+ + setHost(e.target.value)} + onPaste={(e) => { + if (onHostInput(e.clipboardData.getData("text"))) e.preventDefault(); + }} + onBlur={() => onHostInput(host)} + placeholder="203.0.113.10 or web-1.example.com" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="font-mono text-[13px] placeholder:font-sans" + aria-invalid={!!err("host")} + aria-describedby={err("host") ? "ssh-host-error" : undefined} + /> + + + setPort(e.target.value.replace(/\D/g, "").slice(0, 5))} + inputMode="numeric" + autoComplete="off" + className="font-mono text-[13px] tabular-nums" + aria-invalid={!!err("port")} + aria-describedby={err("port") ? "ssh-port-error" : undefined} + /> + +
+ +
+ + setUsername(e.target.value)} + placeholder="root" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="font-mono text-[13px] placeholder:font-sans" + aria-invalid={!!err("username")} + aria-describedby={err("username") ? "ssh-user-error" : undefined} + /> + +
+ + Sign in with + + v && setAuth(v as SshAuthMethod)} + aria-labelledby="ssh-auth-label" + className="w-full" + > + + Password + + + SSH key + + +
+
+ +
+ {auth === "password" ? ( + + setPassword(e.target.value)} + placeholder={editing && savedLoginPassword ? "Saved β€” leave empty to keep" : "Password for this user"} + aria-invalid={!!err("password")} + aria-describedby={err("password") ? "ssh-password-error" : undefined} + /> + + ) : ( + <> +
+
+ + {replacingKey && ( + + )} +
+ + {savedKey ? ( +
+
+ +
+
+

+ {keyTypeLabel(savedKey.type)} key Β· saved in the vault +

+

+ {savedKey.fingerprint} +

+
+ + +
+ ) : keySource.kind !== "paste" ? ( +
+ + + {keySource.kind === "local" ? {short(keySource.key.path)} : New key} + + {" "} + Β· {keyTypeLabel(keySource.key.type)} Β· {shortFingerprint(keySource.key.fingerprint, 20)} + + + +
+ ) : ( + <> +