From 1073a527926061961147e6f784729f66baf55fdf Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 30 Sep 2026 00:59:41 +0200 Subject: [PATCH 1/3] Let agents work on SSH servers with a password or key (GODM-36) SSH servers are saved with their password, private key and passphrase sealed in the vault and assigned to agents (every run) or chats (the SSH chip). Runs get an ssh MCP server: shell with sudo answered by Godmode, SFTP file tools with a shell fallback, and uploads/downloads within the run's folders. Host keys are pinned on the first connection. The SSH servers page adds, tests and assigns servers and runs quick commands. --- README.md | 16 + SECURITY.md | 7 + apps/desktop/src/App.tsx | 2 + .../src/components/agents/agent-form.tsx | 73 +- apps/desktop/src/components/chat/tool-meta.ts | 44 + .../src/components/layout/app-shell.tsx | 2 + .../src/components/layout/command-palette.tsx | 4 + .../src/components/ssh/server-card.tsx | 403 +++++++++ .../src/components/ssh/server-dialog.tsx | 800 ++++++++++++++++++ apps/desktop/src/components/ssh/ssh-chip.tsx | 165 ++++ apps/desktop/src/components/ssh/ssh-parts.tsx | 120 +++ .../src/components/ssh/use-ssh-actions.ts | 100 +++ apps/desktop/src/lib/api.ts | 27 + apps/desktop/src/lib/hooks.ts | 5 + apps/desktop/src/lib/queryKeys.ts | 2 + apps/desktop/src/lib/realtime.ts | 1 + .../src/pages/chat/chat-conversation.tsx | 36 +- apps/desktop/src/pages/chat/chat-home.tsx | 6 + apps/desktop/src/pages/ssh/ssh-page.tsx | 125 +++ docs/ARCHITECTURE.md | 36 + package.json | 6 + packages/core/package.json | 2 + packages/core/scripts/build.ts | 2 + packages/core/src/agents/service.ts | 9 + packages/core/src/backup/backup.ts | 1 + packages/core/src/db/migrations.ts | 31 + packages/core/src/index.ts | 2 + packages/core/src/mcp/http.ts | 17 +- packages/core/src/runner/mcpConfig.ts | 25 +- packages/core/src/runner/prompt.ts | 30 +- packages/core/src/runner/runner.ts | 13 +- packages/core/src/server/app.ts | 2 + packages/core/src/server/routes/agents.ts | 1 + packages/core/src/server/routes/chat.ts | 6 +- packages/core/src/server/routes/ssh.ts | 71 ++ packages/core/src/services/conversations.ts | 12 + packages/core/src/ssh/assignments.ts | 58 ++ packages/core/src/ssh/client.ts | 596 +++++++++++++ packages/core/src/ssh/keys.ts | 170 ++++ packages/core/src/ssh/service.ts | 514 +++++++++++ packages/core/src/ssh/tools.ts | 412 +++++++++ packages/core/src/vault/vault.ts | 16 + packages/core/test/composio.test.ts | 1 + packages/core/test/credentials.test.ts | 1 + packages/core/test/fixtures/fake-claude.ts | 49 ++ packages/core/test/fixtures/ssh-server.ts | 256 ++++++ packages/core/test/mcp-servers.test.ts | 1 + packages/core/test/scheduler.test.ts | 1 + packages/core/test/ssh.test.ts | 369 ++++++++ packages/core/test/totp.test.ts | 1 + packages/shared/src/api.ts | 6 + packages/shared/src/constants.ts | 2 + packages/shared/src/events.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/models.ts | 4 + packages/shared/src/ssh.ts | 152 ++++ pnpm-lock.yaml | 84 ++ 57 files changed, 4885 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/components/ssh/server-card.tsx create mode 100644 apps/desktop/src/components/ssh/server-dialog.tsx create mode 100644 apps/desktop/src/components/ssh/ssh-chip.tsx create mode 100644 apps/desktop/src/components/ssh/ssh-parts.tsx create mode 100644 apps/desktop/src/components/ssh/use-ssh-actions.ts create mode 100644 apps/desktop/src/pages/ssh/ssh-page.tsx create mode 100644 packages/core/src/server/routes/ssh.ts create mode 100644 packages/core/src/ssh/assignments.ts create mode 100644 packages/core/src/ssh/client.ts create mode 100644 packages/core/src/ssh/keys.ts create mode 100644 packages/core/src/ssh/service.ts create mode 100644 packages/core/src/ssh/tools.ts create mode 100644 packages/core/test/fixtures/ssh-server.ts create mode 100644 packages/core/test/ssh.test.ts create mode 100644 packages/shared/src/ssh.ts diff --git a/README.md b/README.md index f4035579..d9e87d5c 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ needs to be useful: | 🌐 **Real browser** | browser-use drives a managed Chromium over CDP. Watch it live right next to the chat and *take control* for CAPTCHAs. | | πŸ–₯️ **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. | @@ -209,6 +210,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, masked in transcripts and never part of a tool result β€” also when a command prints them. | +| **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 @@ -304,6 +319,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). - [x] Computer use: share a window (background control via Cua Driver), a display, every monitor or a browser tab - [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] 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..705ffec3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,6 +43,13 @@ 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 + and answers `sudo` with the password sealed in the vault; the password, private key and passphrase never appear in a + tool result (masked even when a command prints them). 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. First use per run + and every sudo password entry are audited (`ssh.use`, `ssh.sudo`). An agent with a server can do whatever that user + may do there β€” give it an account with only the rights the work needs. ## Important caveats diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d6aedfda..dced31a0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -30,6 +30,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")); @@ -114,6 +115,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 2f2795f3..011b1e28 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useLocation } from "react-router"; +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, @@ -24,12 +26,13 @@ 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 { cn } from "@/lib/utils"; import { AgentAvatar, 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"; @@ -45,6 +48,7 @@ import { useVaultGrant } from "@/components/vault/grant"; import { FolderPickerDialog, folderName, useShortPath } from "@/components/chat/folder-picker"; import { InheritedInstructions, useInheritedInstructions } from "@/components/instructions/instructions"; import { VmSelectField } from "@/components/vms/vm-picker"; +import { SSH_STATUS_LABEL, SshStatusDot, sshAddress, sshStatus } from "@/components/ssh/ssh-parts"; export interface AgentFormValues { name: string; @@ -73,6 +77,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. */ @@ -104,6 +109,7 @@ export function agentToValues( subagents: source?.subagents ?? [], workingDirectory: source?.workingDirectory ?? null, vmId: source?.vmId ?? null, + sshServerIds: source?.sshServerIds ?? [], }; } @@ -134,6 +140,7 @@ export function valuesToInput(v: AgentFormValues): AgentInput { .filter((s) => s.name), workingDirectory: v.workingDirectory, vmId: v.vmId, + sshServerIds: v.sshServerIds, }; } @@ -172,6 +179,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" }, ]; @@ -580,6 +588,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 && ( @@ -855,6 +873,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 66a9caac..ce61da5a 100644 --- a/apps/desktop/src/components/chat/tool-meta.ts +++ b/apps/desktop/src/components/chat/tool-meta.ts @@ -8,10 +8,12 @@ import { Camera, Code2, Eye, + FileDown, FilePen, FilePlus2, FileSearch, FileText, + FileUp, FolderSearch, Globe, History, @@ -33,6 +35,7 @@ import { ScanText, ScrollText, Search, + Server, ShieldAlert, ShieldCheck, Terminal, @@ -225,6 +228,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); @@ -408,6 +451,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 68b371dd..6a7cc5a1 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, Workflow, @@ -114,6 +115,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 987ea76d..89743371 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, @@ -129,6 +130,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..d2accafe --- /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)} + + + +
+ ) : ( + <> +