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:
Tools β APIs agents use with your keys, scoped globally, per workspace or per agent
Add a tool β presets bring the address and docs, you add the key
+
+ SSH servers β password or key, sealed in the vault; host keys pinned on the first connection
+ 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.
+
+
+ Add a server
+
+
+
+ );
+ }
+ 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 (
+
+ toggle(s.id, v === true)} />
+
+ {s.name}
+ {sshAddress(s)}
+
+
+
+ {SSH_STATUS_LABEL[sshStatus(s)]}
+
+
+ );
+ })}
+
+
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}
+
actions.test.mutate({ server })}>
+ Test again
+
+
+ )}
+
+
+
+
+
+
+
+
+ actions.test.mutate({ server })}
+ className="h-7 gap-1.5 px-2 text-[12.5px] font-normal text-muted-foreground hover:text-foreground [&_svg]:size-3.5"
+ >
+ {testing ? : }
+ {testing ? "Testingβ¦" : "Test connection"}
+
+
+ {server.hostKey ? "Sign in and check the host key" : "Sign in and pin the server's host key"}
+
+ setCommandOpen((o) => !o)}
+ >
+ Run command
+
+
+
+
+
+ {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 (
+
+
+
+ {pending ? : }
+
+
+
+
+ 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}
+
+ actions.assign.mutate({ server, kind: a.kind, id: a.id, name: label, assigned: false })}
+ className="grid h-full w-5 shrink-0 place-items-center rounded-r-md text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
+ >
+ {removing(a) ? : }
+
+
+ );
+ })}
+
+
+ );
+}
+
+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 (
+
+
+
+ Assign
+
+
+
+
+ {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 (
+
+ );
+}
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 (
+
+
{label}
+ {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 (
+
+
+
+
+
+ );
+}
+
+function AuthorizeKeyCallout({ publicKey }: { publicKey: string }) {
+ const command = `mkdir -p ~/.ssh && echo '${publicKey.trim()}' >> ~/.ssh/authorized_keys`;
+ return (
+
+
+
Add the public key to the server before testing
+
Sign in another way once (your hosting panel or console) and run this β then Godmode can use the key.
+
+
+
+
+ Public key only
+
+
+
+
+
+
+ );
+}
+
+function CodeLine({ text, label }: { text: string; label: string }) {
+ return (
+
+ {text}
+
+
+ );
+}
+
+function TestResultPanel({
+ state,
+ username,
+ pinned,
+ trustNewKey,
+ onTrustNewKey,
+}: {
+ state: TestState;
+ username: string;
+ pinned: SshHostKey | null;
+ trustNewKey: boolean;
+ onTrustNewKey: (v: boolean) => void;
+}) {
+ if ("error" in state) {
+ return (
+
+ );
+ }
+ const r = state.result;
+
+ if (r.hostKeyChanged && r.hostKey) {
+ return (
+
+
+
+
+
The server's host key changed
+
+ That's expected after a reinstall. If nothing changed on the server, someone may be intercepting the connection β don't trust the new key.
+
+
+ {keyTypeLabel(r.hostKey.type)} {r.hostKey.fingerprint}
+
+
+
+
+ onTrustNewKey(v === true)} />
+ Trust the new host key
+
+
+ );
+ }
+
+ if (!r.ok) {
+ return (
+
+
+
+
{r.stage ? STAGE_TITLE[r.stage] : "Couldn't connect"}
+ {r.error &&
{r.error}
}
+
+
+ );
+ }
+
+ const same = !!pinned && !!r.hostKey && pinned.fingerprint === r.hostKey.fingerprint;
+ return (
+
+
+
+
+ Signed in as {username}
+
+ {[r.latencyMs !== null ? `${r.latencyMs} ms` : null, r.os].filter(Boolean).map((part) => `Β· ${part}`).join(" ")}
+
+ {r.hostKey && (
+
+
+
+ {keyTypeLabel(r.hostKey.type)} {r.hostKey.fingerprint}
+
+ {same ? "Matches the pinned host key." : "Godmode trusts only this host key from now on."}
+
+
+ )}
+
+ );
+}
+
+export function DeleteServerDialog({ server, onClose, actions }: { server: SshServer | null; onClose: () => void; actions: SshActions }) {
+ const users = server?.assignments.length ?? 0;
+ const secrets = server?.auth === "key" ? (server.hasPassword ? "key and sudo password" : "key") : "password";
+ return (
+ !o && onClose()}>
+
+
+ Delete β{server?.name}β?
+
+ Godmode forgets the server and deletes its saved {secrets} from the vault.
+ {users === 1 && ` ${server?.assignments[0]?.name || "The agent or chat using it"} loses access to it.`}
+ {users > 1 && ` The ${users} agents and chats using it lose access to it.`} The server itself isn't touched.
+
+
+
+ Cancel
+ {
+ if (server) actions.remove.mutate(server);
+ onClose();
+ }}
+ >
+ Delete server
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/ssh/ssh-chip.tsx b/apps/desktop/src/components/ssh/ssh-chip.tsx
new file mode 100644
index 00000000..5577f5eb
--- /dev/null
+++ b/apps/desktop/src/components/ssh/ssh-chip.tsx
@@ -0,0 +1,166 @@
+import { useState } from "react";
+import { useNavigate } from "react-router";
+import { Check, ChevronDown, Loader2, Lock, Plus, Server, Settings2 } from "lucide-react";
+import type { Agent, SshServer } from "@godmode/shared";
+import { Button } from "@/components/ui/button";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useSshServers } from "@/lib/hooks";
+import { cn } from "@/lib/utils";
+import { SshStatusDot, sshAddress } from "./ssh-parts";
+
+function listNames(servers: SshServer[]): string {
+ const names = servers.map((s) => s.name);
+ if (names.length <= 2) return names.join(" and ");
+ return `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
+}
+
+/**
+ * Composer control for the SSH servers a chat can sign in to: a quiet button while the run would have none, a pill
+ * with the first server's name otherwise (dashed when they all come from the agent). The agent's own servers are
+ * always on; the chat adds more.
+ */
+export function SshChip({
+ agent,
+ value,
+ onChange,
+ busy,
+}: {
+ agent: Agent | undefined;
+ /** The chat's own servers (the agent's apply anyway). */
+ value: string[];
+ onChange: (sshServerIds: string[]) => void | Promise;
+ busy?: boolean;
+}) {
+ const { data: servers = [], isLoading } = useSshServers();
+ const navigate = useNavigate();
+ const [open, setOpen] = useState(false);
+ if (!agent) return null;
+
+ const fromAgent = new Set(agent.sshServerIds ?? []);
+ const own = servers.filter((s) => value.includes(s.id) && !fromAgent.has(s.id));
+ const inherited = servers.filter((s) => fromAgent.has(s.id));
+ const active = [...own, ...inherited];
+ const first = active[0];
+
+ const known = value.filter((id) => servers.some((s) => s.id === id));
+ const toggle = (id: string) => void onChange(known.includes(id) ? known.filter((x) => x !== id) : [...known, id]);
+ const go = (to: string) => {
+ setOpen(false);
+ navigate(to);
+ };
+
+ const tooltip = !first
+ ? "Let this chat sign in to your servers"
+ : own.length === 0
+ ? `Can sign in to ${listNames(inherited)} β assigned to ${agent.name}`
+ : `This chat can sign in to ${listNames(active)}`;
+ const icon = busy ? : ;
+
+ return (
+
+
+
+
+ {first ? (
+ s.name).join(", ")}`}
+ className={cn(
+ "flex h-8 max-w-[12rem] min-w-14 shrink items-center gap-1.5 rounded-lg border pr-1.5 pl-2 text-[13px] transition focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
+ own.length > 0 ? "bg-card hover:bg-accent" : "border-dashed text-muted-foreground hover:bg-accent hover:text-foreground",
+ )}
+ >
+ {icon}
+ 0 && "font-medium")}>{first.name}
+ {active.length > 1 && (
+ +{active.length - 1}
+ )}
+
+
+ ) : (
+ svg]:px-2 [&_svg:not([class*='size-'])]:size-4",
+ open && "bg-accent text-foreground",
+ )}
+ >
+ {busy ? : }
+ SSH
+
+ )}
+
+
+ {tooltip}
+
+
+
+
SSH servers
+
Runs in this chat can sign in, run commands and move files. Godmode types the password or key.
+
+ {!isLoading && servers.length === 0 ? (
+
+
No SSH servers yet.
+
go("/ssh?new=1")}>
+ Add server
+
+
+ ) : (
+ <>
+
+ {servers.length > 6 && }
+
+ No server with that name.
+
+ {servers.map((s) => {
+ const locked = fromAgent.has(s.id);
+ const on = locked || value.includes(s.id);
+ return (
+ toggle(s.id)}
+ className="gap-2.5 py-2 data-[disabled=true]:opacity-100"
+ >
+
+
+
+
+
+ {s.name}
+
+
+ {locked ? (
+
+ Assigned to {agent.name}
+
+ ) : (
+ {sshAddress(s)}
+ )}
+
+ {on && }
+
+ );
+ })}
+
+
+
+
+
go("/ssh?new=1")}>
+ Add server
+
+
go("/ssh")}>
+ Manage
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/ssh/ssh-parts.tsx b/apps/desktop/src/components/ssh/ssh-parts.tsx
new file mode 100644
index 00000000..d8faf1d0
--- /dev/null
+++ b/apps/desktop/src/components/ssh/ssh-parts.tsx
@@ -0,0 +1,120 @@
+import { format, formatDistanceToNowStrict } from "date-fns";
+import { TriangleAlert } from "lucide-react";
+import type { SshServer } from "@godmode/shared";
+import { LiveDot } from "@/components/aicss/Motion";
+import { Spinner } from "@/components/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+export type SshStatus = "connected" | "failing" | "untested";
+
+export function sshStatus(server: Pick): SshStatus {
+ if (server.lastError) return "failing";
+ return server.lastConnectedAt ? "connected" : "untested";
+}
+
+/** "deploy@web-1.example.com", with the port only when it isn't 22 (IPv6 hosts in brackets then). */
+export function sshAddress(server: Pick): string {
+ if (server.port === 22) return `${server.username}@${server.host}`;
+ const host = server.host.includes(":") ? `[${server.host}]` : server.host;
+ return `${server.username}@${host}:${server.port}`;
+}
+
+/** "ssh-ed25519" β "ED25519", "ecdsa-sha2-nistp256" β "ECDSA", "ssh-rsa" β "RSA". */
+export function keyTypeLabel(type: string): string {
+ const sk = /^sk-/i.test(type) ? "-SK" : "";
+ if (/ed25519/i.test(type)) return `ED25519${sk}`;
+ if (/ecdsa/i.test(type)) return `ECDSA${sk}`;
+ if (/rsa/i.test(type)) return "RSA";
+ if (/dss|dsa/i.test(type)) return "DSA";
+ return type.replace(/^ssh-/i, "").toUpperCase();
+}
+
+export function shortFingerprint(fingerprint: string, length = 18): string {
+ return fingerprint.length > length ? `${fingerprint.slice(0, length)}β¦` : fingerprint;
+}
+
+/** "just now", "5 min ago", "3 h ago", then "4 days ago". */
+export function timeAgo(iso: string): string {
+ const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
+ if (seconds < 45) return "just now";
+ if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))} min ago`;
+ if (seconds < 86_400) return `${Math.round(seconds / 3600)} h ago`;
+ return formatDistanceToNowStrict(new Date(iso), { addSuffix: true });
+}
+
+/** `user@host:port`, `ssh -p 2222 user@host` or `host:port` pasted into the host field. */
+export function parseSshTarget(text: string): { host: string; username?: string; port?: number } | null {
+ const m = text.trim().match(/^(?:ssh\s+)?(?:-p\s*(\d{1,5})\s+)?(?:([^@\s]+)@)?(\[[^\]]+\]|[^\s:@/]+)(?::(\d{1,5}))?$/);
+ if (!m) return null;
+ const [, flagPort, username, rawHost, suffixPort] = m;
+ if (!username && !flagPort && !suffixPort) return null;
+ const port = Number(flagPort ?? suffixPort);
+ return { host: rawHost.replace(/^\[|\]$/g, ""), username, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined };
+}
+
+/** Small dot for lists: green after a successful sign-in, red while failing, muted until the first test. */
+export function SshStatusDot({ server, className }: { server: Pick; className?: string }) {
+ const status = sshStatus(server);
+ if (status === "connected") return ;
+ return (
+
+ );
+}
+
+export const SSH_STATUS_LABEL: Record = {
+ connected: "Connected",
+ failing: "Can't connect",
+ untested: "Not tested yet",
+};
+
+const BADGE: Record = {
+ connected: "border-brand/25 bg-brand-soft text-brand-strong",
+ failing: "border-destructive/25 bg-destructive/[0.06] text-destructive",
+ untested: "border-border bg-secondary text-muted-foreground",
+ testing: "border-border bg-secondary text-foreground",
+};
+
+export function SshStatusBadge({ server, testing, className }: { server: SshServer; testing?: boolean; className?: string }) {
+ const status = sshStatus(server);
+ const badge = (
+
+ {testing ? (
+ <>
+ Testingβ¦
+ >
+ ) : status === "connected" ? (
+ <>
+
+ {SSH_STATUS_LABEL.connected}
+ Β· {timeAgo(server.lastConnectedAt!)}
+ >
+ ) : status === "failing" ? (
+ <>
+ {SSH_STATUS_LABEL.failing}
+ >
+ ) : (
+ <>
+ {SSH_STATUS_LABEL.untested}
+ >
+ )}
+
+ );
+ if (testing || status !== "connected") return badge;
+ return (
+
+ {badge}
+ Last signed in {format(new Date(server.lastConnectedAt!), "PPp")}
+
+ );
+}
diff --git a/apps/desktop/src/components/ssh/use-ssh-actions.ts b/apps/desktop/src/components/ssh/use-ssh-actions.ts
new file mode 100644
index 00000000..71db04ab
--- /dev/null
+++ b/apps/desktop/src/components/ssh/use-ssh-actions.ts
@@ -0,0 +1,100 @@
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import type { SshAssignmentKind, SshServer, SshTestResult } from "@godmode/shared";
+import { copyText } from "@/components/chat/copy-button";
+import { toastApiError } from "@/components/vault/vault-utils";
+import { api } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+
+/** Why a test failed, for a toast: the host key mismatch gets a way out. */
+export function testFailure(res: SshTestResult): string | undefined {
+ if (res.hostKeyChanged) return "The server presented a different host key than the pinned one. If it was reinstalled, forget the host key (β― menu) and test again.";
+ return res.error ?? undefined;
+}
+
+/**
+ * Test, forget host key, delete, copy public key and assignment for SSH servers β with toasts, and the answer patched
+ * into the cached list right away (realtime `entity.changed` keeps it current afterwards).
+ */
+export function useSshActions() {
+ const qc = useQueryClient();
+ const [testing, setTesting] = useState>(new Set());
+
+ const put = (server: SshServer) => {
+ qc.setQueryData(qk.sshServers, (old) =>
+ old ? (old.some((s) => s.id === server.id) ? old.map((s) => (s.id === server.id ? server : s)) : [...old, server]) : old,
+ );
+ };
+
+ const test = useMutation({
+ mutationFn: ({ server }: { server: SshServer; silent?: boolean }) => api.ssh.test(server.id),
+ onMutate: ({ server }) => setTesting((s) => new Set(s).add(server.id)),
+ onSuccess: (res, { server, silent }) => {
+ if (silent) return;
+ if (res.ok) {
+ toast.success(`Signed in to ${server.name}`, {
+ description: [res.latencyMs !== null ? `${res.latencyMs} ms` : null, res.os].filter(Boolean).join(" Β· ") || undefined,
+ });
+ } else toast.error(`Couldn't sign in to ${server.name}`, { description: testFailure(res) });
+ },
+ onError: (e, { server, silent }) => {
+ if (!silent) toastApiError(e, `Couldn't test β${server.name}β`, qc);
+ },
+ onSettled: (_res, _e, { server }) => {
+ setTesting((s) => {
+ const next = new Set(s);
+ next.delete(server.id);
+ return next;
+ });
+ void qc.invalidateQueries({ queryKey: qk.sshServers });
+ },
+ });
+
+ const forgetHostKey = useMutation({
+ mutationFn: (server: SshServer) => api.ssh.update(server.id, { hostKey: null }),
+ onSuccess: (next) => {
+ put(next);
+ toast.success("Host key forgotten", { description: "The next connection pins the key the server presents." });
+ },
+ onError: (e) => toastApiError(e, "Couldn't forget the host key", qc),
+ });
+
+ const remove = useMutation({
+ mutationFn: (server: SshServer) => api.ssh.delete(server.id),
+ onSuccess: (_res, server) => {
+ qc.setQueryData(qk.sshServers, (old) => old?.filter((s) => s.id !== server.id));
+ void qc.invalidateQueries({ queryKey: qk.agents });
+ toast.success(`${server.name} deleted`, { description: "Its saved password and key were removed from the vault." });
+ },
+ onError: (e, server) => toastApiError(e, `Couldn't delete β${server.name}β`, qc),
+ });
+
+ const assign = useMutation({
+ mutationFn: ({ server, kind, id, assigned }: { server: SshServer; kind: SshAssignmentKind; id: string; name: string; assigned: boolean }) =>
+ api.ssh.assign(server.id, { kind, id, assigned }),
+ onSuccess: (next, { kind, id, name, assigned }) => {
+ put(next);
+ void qc.invalidateQueries({ queryKey: kind === "agent" ? qk.agents : qk.conversation(id) });
+ if (assigned)
+ toast.success(`${name} can use ${next.name}`, {
+ description: kind === "agent" ? "Every run of the agent can sign in to it." : "Runs in the chat can sign in to it.",
+ });
+ else
+ toast.success(`${name} no longer uses ${next.name}`, {
+ description: kind === "agent" ? "Chats that picked the server themselves keep it." : "Runs in the chat can't sign in to it anymore, unless its agent has it.",
+ });
+ },
+ onError: (e) => toastApiError(e, "Couldn't change the assignment", qc),
+ });
+
+ const copyPublicKey = async (server: SshServer) => {
+ if (!server.key) return;
+ if (await copyText(server.key.publicKey)) toast.success("Public key copied", { description: "Add it to ~/.ssh/authorized_keys on the server." });
+ else toast.error("Couldn't copy the public key");
+ };
+
+ return { test, testing, forgetHostKey, remove, assign, copyPublicKey, put };
+}
+
+export type SshActions = ReturnType;
diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts
index c08ff30b..26792750 100644
--- a/apps/desktop/src/lib/api.ts
+++ b/apps/desktop/src/lib/api.ts
@@ -69,6 +69,16 @@ import type {
Settings,
SetupInput,
SlashCommand,
+ SshAssignInput,
+ SshExecInput,
+ SshExecResult,
+ SshGeneratedKey,
+ SshLocalKey,
+ SshServer,
+ SshServerInput,
+ SshServerPatch,
+ SshTestInput,
+ SshTestResult,
StartChatInput,
StartChatResult,
Task,
@@ -407,6 +417,23 @@ export const api = {
assign: (id: string, input: VmAssignInput) => post(`/api/vms/${id}/assign`, input),
},
+ ssh: {
+ list: () => get("/api/ssh/servers"),
+ get: (id: string) => get(`/api/ssh/servers/${id}`),
+ create: (input: SshServerInput) => post("/api/ssh/servers", input),
+ update: (id: string, input: SshServerPatch) => patch(`/api/ssh/servers/${id}`, input),
+ delete: (id: string) => del<{ ok: true }>(`/api/ssh/servers/${id}`),
+ /** Sign in to a saved server; pins its host key when none is pinned yet. */
+ test: (id: string) => post(`/api/ssh/servers/${id}/test`),
+ /** Try an unsaved (or edited) server; `id` fills in the saved secrets the input leaves out. */
+ try: (input: SshTestInput) => post("/api/ssh/test", input),
+ exec: (id: string, input: SshExecInput) => post(`/api/ssh/servers/${id}/exec`, input),
+ assign: (id: string, input: SshAssignInput) => post(`/api/ssh/servers/${id}/assign`, input),
+ /** Private keys in ~/.ssh on the computer running Godmode. */
+ localKeys: () => get("/api/ssh/local-keys"),
+ generateKey: (comment?: string) => post("/api/ssh/keys", comment ? { comment } : {}),
+ },
+
chat: {
/** Create a conversation and send the first message in one call. */
start: (input: StartChatInput) => post("/api/chat", input),
diff --git a/apps/desktop/src/lib/hooks.ts b/apps/desktop/src/lib/hooks.ts
index 68413653..2ed1eb9e 100644
--- a/apps/desktop/src/lib/hooks.ts
+++ b/apps/desktop/src/lib/hooks.ts
@@ -167,6 +167,11 @@ export function useVmChoices() {
return { available, vms: list.data ?? [], isLoading: list.isLoading };
}
+/** SSH servers agents and chats can use (kept current by `entity.changed` "ssh-servers"). */
+export function useSshServers() {
+ return useQuery({ queryKey: qk.sshServers, queryFn: api.ssh.list });
+}
+
export function useAgentTemplates() {
return useQuery({ queryKey: qk.agentTemplates, queryFn: api.agents.templates, staleTime: 5 * 60_000 });
}
diff --git a/apps/desktop/src/lib/queryKeys.ts b/apps/desktop/src/lib/queryKeys.ts
index b127e73f..41ae6062 100644
--- a/apps/desktop/src/lib/queryKeys.ts
+++ b/apps/desktop/src/lib/queryKeys.ts
@@ -77,6 +77,8 @@ export const qk = {
vmList: ["vms", "list"] as unknown[],
vmStatus: ["vms", "status"] as unknown[],
vmScreen: (id: string, size: number) => ["vm-screen", id, size],
+ sshServers: ["ssh-servers"] as unknown[],
+ sshLocalKeys: ["ssh-local-keys"] as unknown[],
chromeProfiles: ["chrome-profiles"] as unknown[],
folders: ["folders"] as unknown[],
folderList: (path: string, hidden: boolean) => ["folders", "list", path, hidden],
diff --git a/apps/desktop/src/lib/realtime.ts b/apps/desktop/src/lib/realtime.ts
index 79ced327..7ff82604 100644
--- a/apps/desktop/src/lib/realtime.ts
+++ b/apps/desktop/src/lib/realtime.ts
@@ -42,6 +42,7 @@ const ENTITY_KEYS: Record = {
computer: [qk.computer],
// VM list + status (installs, image downloads, assignment changes).
vms: [qk.vms],
+ "ssh-servers": [qk.sshServers],
// Bot status, access requests and chats (the sidebar badge counts requests).
messaging: [qk.messaging, qk.bootstrap],
tasks: [qk.tasks],
diff --git a/apps/desktop/src/pages/chat/chat-conversation.tsx b/apps/desktop/src/pages/chat/chat-conversation.tsx
index 12cc341a..c8a504ae 100644
--- a/apps/desktop/src/pages/chat/chat-conversation.tsx
+++ b/apps/desktop/src/pages/chat/chat-conversation.tsx
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
-import type { Agent, BrowserProfile, ComputerTarget, ConversationWithMessages, Message, SendMessageInput, Vm } from "@godmode/shared";
+import type { Agent, BrowserProfile, ComputerTarget, ConversationWithMessages, Message, SendMessageInput, SshServer, Vm } from "@godmode/shared";
import { computerTargetLabel } from "@godmode/shared";
import { Archive, ArchiveRestore, ArrowUpRight, Brain, MessageSquareDashed, MessageSquarePlus, Moon, Sparkles, Wand2 } from "lucide-react";
import { toast } from "sonner";
@@ -21,6 +21,7 @@ import { FollowupBar } from "@/components/chat/followup";
import { ModelPicker, type ModelChoice } from "@/components/chat/model-picker";
import { FolderChip, folderName } from "@/components/chat/folder-picker";
import { InstructionsChip } from "@/components/instructions/instructions";
+import { SshChip } from "@/components/ssh/ssh-chip";
import { VmChip } from "@/components/vms/vm-picker";
import { VmFocus, VmPanel, VmToggle, useChatVm } from "@/components/vms/vm-panel";
import { ChatDropZone, Thread } from "@/components/chat/thread";
@@ -243,6 +244,35 @@ function ConversationView({ conversationId }: { conversationId: string }) {
onError: (err) => toast.error("Couldn't change the browser profile", { description: errorMessage(err) }),
});
+ const setSshServers = useMutation({
+ // One change at a time, in the order they were made.
+ scope: { id: `ssh-servers:${conversationId}` },
+ mutationFn: (sshServerIds: string[]) => api.conversations.update(conversationId, { sshServerIds }),
+ onMutate: (next) => {
+ const prev = qc.getQueryData(key)?.sshServerIds ?? [];
+ qc.setQueryData(key, (c) => (c ? { ...c, sshServerIds: next } : c));
+ return { prev };
+ },
+ onSuccess: (updated, next, ctx) => {
+ // The cache already holds the latest pick (toggles can overlap); take everything else from the answer.
+ qc.setQueryData(key, (old) => (old ? { ...old, ...updated, sshServerIds: old.sshServerIds } : old));
+ qc.invalidateQueries({ queryKey: qk.sshServers });
+ const servers = qc.getQueryData(qk.sshServers) ?? [];
+ const nameOf = (id: string) => servers.find((s) => s.id === id)?.name ?? "the server";
+ const added = next.find((id) => !ctx.prev.includes(id));
+ const removed = ctx.prev.find((id) => !next.includes(id));
+ if (added)
+ toast.success(`Next message can use ${nameOf(added)}`, {
+ description: "Godmode signs in for the agent β the password or key stays in the vault.",
+ });
+ else if (removed) toast.success(`Removed ${nameOf(removed)}`, { description: "Runs in this chat can't sign in to it anymore." });
+ },
+ onError: (err) => {
+ void qc.invalidateQueries({ queryKey: key });
+ toast.error("Couldn't change the SSH servers", { description: errorMessage(err) });
+ },
+ });
+
const share = useMutation({
mutationFn: (computerTarget: ComputerTarget | null) => api.conversations.update(conversationId, { computerTarget }),
onSuccess: (updated, target) => {
@@ -428,6 +458,12 @@ function ConversationView({ conversationId }: { conversationId: string }) {
/>
>
)}
+ setSshServers.mutateAsync(ids).catch(() => undefined)}
+ busy={setSshServers.isPending}
+ />
({
initial: { opacity: 0, y: 10 },
@@ -90,7 +92,9 @@ export default function ChatHome() {
const [vmId, setVmId, vmDraft] = useDraft(`${SETUP_DRAFT}vm`, null);
/** Browser profile for the new chat; null = the agent's. */
const [browserProfileId, setBrowserProfileId, browserDraft] = useDraft(`${SETUP_DRAFT}browser`, null);
- const resetSetup = () => [agentDraft, choiceDraft, folderDraft, sharedDraft, instructionsDraft, vmDraft, browserDraft].forEach((d) => d.discard());
+ /** SSH servers for the new chat, on top of the agent's. */
+ const [sshServerIds, setSshServerIds, sshDraft] = useDraft(`${SETUP_DRAFT}ssh`, NO_SSH_SERVERS);
+ const resetSetup = () => [agentDraft, choiceDraft, folderDraft, sharedDraft, instructionsDraft, vmDraft, browserDraft, sshDraft].forEach((d) => d.discard());
const { data: workspaces = [] } = useWorkspaces();
const scopeWorkspaceId = useScopeWorkspace()?.id ?? null;
@@ -181,6 +185,7 @@ export default function ChatHome() {
/>
+
(null);
+ const [deleteId, setDeleteId] = useState(null);
+
+ const byId = (id: string | null) => (id ? (servers.find((s) => s.id === id) ?? null) : null);
+ const openAdd = () => {
+ setDialogId(null);
+ setDialogOpen(true);
+ };
+ const openEdit = (id: string) => {
+ setDialogId(id);
+ setDialogOpen(true);
+ };
+
+ // Deep links from other screens: /ssh?new=1 opens the add dialog, /ssh?edit= the edit dialog.
+ const wantsNew = params.get("new") === "1";
+ const wantsEdit = params.get("edit");
+ useEffect(() => {
+ if (!wantsNew && !wantsEdit) return;
+ if (wantsEdit && !list.data) return;
+ if (wantsNew) openAdd();
+ else if (wantsEdit && list.data?.some((s) => s.id === wantsEdit)) openEdit(wantsEdit);
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.delete("new");
+ next.delete("edit");
+ return next;
+ },
+ { replace: true },
+ );
+ }, [wantsNew, wantsEdit, list.data, setParams]);
+
+ const editing = byId(dialogId);
+ useEffect(() => {
+ if (dialogOpen && dialogId && list.data && !editing) setDialogOpen(false);
+ }, [dialogOpen, dialogId, list.data, editing]);
+
+ return (
+
+
}
+ title="SSH servers"
+ description="Remote machines your agents sign in to and control. Passwords and keys stay in the vault β the AI never sees them."
+ actions={
+
+ Add server
+
+ }
+ />
+
+ {list.isLoading ? (
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+ ) : list.isError ? (
+ }
+ title="Couldn't load your servers"
+ description={errorMessage(list.error)}
+ action={
+ list.refetch()}>
+ Try again
+
+ }
+ />
+ ) : servers.length === 0 ? (
+ }
+ title="Let agents work on your servers"
+ description="Save a server once and a chat or agent can sign in, run commands, edit files and move them back and forth. Godmode signs in for them β the password or key never reaches the AI."
+ action={
+
+ Add server
+
+ }
+ />
+ ) : (
+
+
+ {servers.map((server, i) => (
+
+ openEdit(server.id)} onDelete={() => setDeleteId(server.id)} />
+
+ ))}
+
+
+ )}
+
+
+
+
setDeleteId(null)} actions={actions} />
+
+ );
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 04f2a7f7..29dc5c64 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -179,6 +179,9 @@ works in its own tabs (see Browser).
| `followup_schedule({ at \| inMinutes, note })`, `followup_cancel()` | Continue this chat later on its own (see Follow-ups); not in condition checks |
| `api_tools_list()`, `api_tool_docs({ tool })`, `api_tool_request({ tool, method, path, json \| form \| body, query, saveAs })` | Only for agents with API tools: list them, read one's docs, call its API with the key added by Godmode (see Integrations) |
+Runs may get three more servers behind the gateway, all with the same run token: `/mcp/computer` (see Computer use),
+`/mcp/vm` (see macOS virtual machines) and `/mcp/ssh` (see SSH servers).
+
## HTTP API
All routes are under `/api` and require auth except `/api/health` and `/api/auth/*`.
@@ -408,6 +411,43 @@ Agents can work in isolated macOS VMs instead of on the host (`packages/core/src
(never boots a VM). Backups carry VM records and assignments, not disks; a restore keeps this Mac's own VM records, and a
restored VM whose disk is missing shows an error and can be reset.
+## SSH servers
+
+Remote machines agents sign in to and control (`packages/core/src/ssh/`, `/api/ssh`, the **SSH servers** page):
+
+* **Records** (`ssh_servers`): name, host, port, user, `auth` (`password` | `key`) and a description agents read. The
+ password (for key logins: the password sudo asks for), private key and passphrase are sealed with the vault key
+ (`ssh_servers.:`), redacted like other secrets and never returned by the API; `key_info` keeps the key's type,
+ fingerprint and public key. Keys are parsed with ssh2 (OpenSSH, PEM, PuTTY; a passphrase is required and checked on
+ save), can be imported from `~/.ssh` of the core's machine (`GET /api/ssh/local-keys`, `privateKeyPath`: only files
+ listed there) or generated (`POST /api/ssh/keys`, Ed25519).
+* **Assignments**: `agents.ssh_server_ids` (every run of the agent) and `conversations.ssh_server_ids` (the chat's
+ composer chip, `sshServerIds` on `POST /api/chat` / `PATCH /api/conversations/:id`), JSON arrays. A run gets its chat's
+ and its agent's servers (`ssh/assignments.ts`). Only the human assigns: agent management tools can't, and delegated
+ conversations start without the caller's chat servers. Deleting a server removes it everywhere.
+* **Connections** (`ssh/client.ts`): [ssh2](https://github.com/mscdex/ssh2) (pure JavaScript, native bindings are never
+ built, so the compiled core works on every target). One pooled connection per server shared by runs and the human
+ (β€ 6 channels, keepalives, closed after 3 idle minutes or when the server changes). Password logins also answer
+ keyboard-interactive password prompts. The host key is pinned on the first successful connection (SHA-256 fingerprint,
+ like `StrictHostKeyChecking=accept-new`); a different key fails with both fingerprints and must be forgotten by the
+ human (`hostKey: null`; changing host or port forgets it too). `POST /api/ssh/servers/:id/test` signs in, pins the key
+ and records the OS (`uname` + `/etc/os-release`); `POST /api/ssh/test` tries unsaved settings (secrets left out come
+ from the saved server) without recording anything; `POST /api/ssh/servers/:id/exec` is the card's *Run command*.
+* **`ssh` MCP tools** (`/mcp/ssh`, `ssh/tools.ts`, only for runs that had servers when they started; the allowed servers
+ are re-read on every call, so taking one away applies at once; unknown ids in assignments are dropped):
+ `list_servers`, `shell` (command, `cwd`, `stdin`, `timeout_seconds`; `sudo: true` runs `sudo -n` when sudo needs no
+ password, else `sudo -S -k -p ` and writes the saved password only once that marker shows up on
+ stderr β so it never becomes input for the command β then the command's stdin; a second prompt means it was
+ rejected), `read_file` / `write_file` / `edit_file` (SFTP; `cat` through the shell when a server has no SFTP
+ subsystem) and `upload` / `download` (SFTP, any size; local paths must resolve β symlinks followed, dangling ones
+ refused β into the run's folders: its working directory, the agent repo, the VM's shared folder and the workspace's
+ sources; downloads default to `workspace/downloads`, go to a new file that is renamed into place, and never into a
+ `.git` or `.claude` folder). Every result masks the saved password, passphrase and the key's lines. Ending the run
+ aborts its in-flight commands (a timed-out command whose process ignores the closed session may keep running). The
+ system prompt lists the servers (address, OS, description, whether sudo can be answered) with rules for working on
+ real machines; resumed turns restate them. Audit: `ssh.use` (first call per run and server), `ssh.sudo`,
+ `ssh.assign` / `ssh.unassign`.
+
## Automations
An automation (internally a *routine*: table `routines`, `/api/routines`, `routine_*` tools, `state/routines.json`)
diff --git a/docs/screenshots/ssh-chat.png b/docs/screenshots/ssh-chat.png
new file mode 100644
index 00000000..4b93a044
Binary files /dev/null and b/docs/screenshots/ssh-chat.png differ
diff --git a/docs/screenshots/ssh-servers.png b/docs/screenshots/ssh-servers.png
new file mode 100644
index 00000000..93f9cbf2
Binary files /dev/null and b/docs/screenshots/ssh-servers.png differ
diff --git a/package.json b/package.json
index d205e9a5..03abd59f 100644
--- a/package.json
+++ b/package.json
@@ -25,5 +25,11 @@
"start": "pnpm --filter @godmode/core start",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test"
+ },
+ "pnpm": {
+ "neverBuiltDependencies": [
+ "ssh2",
+ "cpu-features"
+ ]
}
}
diff --git a/packages/core/package.json b/packages/core/package.json
index f5ac3da9..362594e3 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -21,10 +21,12 @@
"fflate": "^0.8.3",
"hono": "^4.13.9",
"isomorphic-git": "^1.42.3",
+ "ssh2": "^1.17.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bun": "^1.3.14",
+ "@types/ssh2": "^1.15.6",
"typescript": "^5.9.3"
}
}
diff --git a/packages/core/scripts/build.ts b/packages/core/scripts/build.ts
index 237524a8..3fde91e3 100644
--- a/packages/core/scripts/build.ts
+++ b/packages/core/scripts/build.ts
@@ -202,6 +202,8 @@ async function compile(opts: {
...(windows ? { windows } : {}),
},
format: "esm",
+ // ssh2's optional native helper (never built): ssh2 loads it in a try/catch and uses pure JavaScript without it.
+ external: ["cpu-features"],
minify: true,
sourcemap: "linked",
bytecode: opts.bytecode,
diff --git a/packages/core/src/agents/service.ts b/packages/core/src/agents/service.ts
index 8eec589d..a6ec400c 100644
--- a/packages/core/src/agents/service.ts
+++ b/packages/core/src/agents/service.ts
@@ -32,6 +32,7 @@ import { reloadSchedules } from "../scheduler/scheduler";
import { requestAppTriggerSync } from "../integrations/composioTriggers";
import { badRequest, newId, notFound, now, parseJson, slugify } from "../util";
import { assignmentsChanged, normalizeVmId } from "../vm/assignments";
+import { normalizeSshServerIds, parseServerIds } from "../ssh/assignments";
import {
AGENT_GITIGNORE,
AGENT_REPO_DIRS,
@@ -66,6 +67,7 @@ interface AgentRow {
subagents: string;
working_directory: string | null;
vm_id: string | null;
+ ssh_server_ids: string;
repo_path: string;
last_run_at: string | null;
created_at: string;
@@ -138,6 +140,7 @@ function toModel(r: AgentRow): Agent {
subagents: normalizeSubagents(parseJson(r.subagents, [])),
workingDirectory: r.working_directory,
vmId: r.vm_id ?? null,
+ sshServerIds: parseServerIds(r.ssh_server_ids),
// Derived from the slug so the data dir can move (backup restore, GODMODE_HOME change).
repoPath: repoPathFor(r.slug),
lastRunAt: r.last_run_at,
@@ -169,6 +172,7 @@ function toRow(a: Agent): Record {
subagents: json(a.subagents)!,
working_directory: a.workingDirectory,
vm_id: a.vmId,
+ ssh_server_ids: json(a.sshServerIds)!,
repo_path: a.repoPath,
last_run_at: a.lastRunAt,
created_at: a.createdAt,
@@ -482,6 +486,8 @@ async function createAgentRecord(input: AgentInput, isDefault: boolean, actor: s
workingDirectory: isAgentActor(actor) ? null : normalizeWorkingDirectory(input.workingDirectory),
// A VM only takes host access away, so managers may give one; removing it stays with the human (updateAgent).
vmId: normalizeVmId(input.vmId) ?? null,
+ // Human-only: signing in to remote machines.
+ sshServerIds: isAgentActor(actor) ? [] : (normalizeSshServerIds(input.sshServerIds) ?? []),
repoPath: repoPathFor(slug),
lastRunAt: null,
createdAt: ts,
@@ -502,6 +508,7 @@ async function createAgentRecord(input: AgentInput, isDefault: boolean, actor: s
log.info(`created agent ${agent.slug}${agent.isDefault ? " (default)" : ""}`);
bus.emit({ type: "agent.updated", agent });
if (agent.vmId) assignmentsChanged();
+ if (agent.sshServerIds.length) bus.changed("ssh-servers");
return agent;
}
@@ -555,6 +562,7 @@ export async function updateAgent(id: string, patch: Partial, actor
}
// Moving an agent into a VM only narrows what it reaches on this computer; taking it out is human-only.
if (patch.vmId !== undefined && (patch.vmId || !isAgentActor(actor))) next.vmId = normalizeVmId(patch.vmId) ?? null;
+ if (patch.sshServerIds !== undefined && !isAgentActor(actor)) next.sshServerIds = normalizeSshServerIds(patch.sshServerIds) ?? [];
next.status = !next.enabled ? "disabled" : current.status === "disabled" ? "idle" : current.status;
next.updatedAt = now();
@@ -573,6 +581,7 @@ export async function updateAgent(id: string, patch: Partial, actor
const agent = getAgent(current.id);
bus.emit({ type: "agent.updated", agent });
if (current.vmId !== agent.vmId) assignmentsChanged();
+ if (JSON.stringify(current.sshServerIds) !== JSON.stringify(agent.sshServerIds)) bus.changed("ssh-servers");
if (current.enabled !== agent.enabled || current.workspaceId !== agent.workspaceId) {
reloadSchedules();
requestAppTriggerSync();
diff --git a/packages/core/src/backup/backup.ts b/packages/core/src/backup/backup.ts
index 97786c29..9a18ed32 100644
--- a/packages/core/src/backup/backup.ts
+++ b/packages/core/src/backup/backup.ts
@@ -91,6 +91,7 @@ const ALL_ENTITIES: EntityName[] = [
"settings",
"runs",
"vms",
+ "ssh-servers",
"messaging",
"followups",
];
diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts
index e720f4bf..2f83a587 100644
--- a/packages/core/src/db/migrations.ts
+++ b/packages/core/src/db/migrations.ts
@@ -556,6 +556,37 @@ CREATE TABLE IF NOT EXISTS api_tools (
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_api_tools_scope ON api_tools(workspace_id, agent_id);
+`,
+ },
+ {
+ id: 16,
+ name: "ssh_servers",
+ sql: /* sql */ `
+-- Remote machines agents sign in to over SSH. \`*_enc\` are sealed with the vault key; \`key_info\` describes the private
+-- key (type, fingerprint, public key β nothing secret). The host key is pinned on the first connection.
+CREATE TABLE IF NOT EXISTS ssh_servers (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 22,
+ username TEXT NOT NULL,
+ auth TEXT NOT NULL DEFAULT 'password',
+ description TEXT NOT NULL DEFAULT '',
+ password_enc TEXT,
+ private_key_enc TEXT,
+ passphrase_enc TEXT,
+ key_info TEXT,
+ host_key_type TEXT,
+ host_key_fingerprint TEXT,
+ os TEXT,
+ last_connected_at TEXT,
+ last_error TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+-- SSH servers an agent uses in every run, and the ones a chat adds (JSON arrays of ids).
+ALTER TABLE agents ADD COLUMN ssh_server_ids TEXT NOT NULL DEFAULT '[]';
+ALTER TABLE conversations ADD COLUMN ssh_server_ids TEXT NOT NULL DEFAULT '[]';
`,
},
];
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ada241c7..2262c4e5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -29,6 +29,7 @@ import { startMessaging, stopMessaging } from "./messaging/service";
import { shutdownBrowsers, ensureDefaultProfile } from "./browser/manager";
import { shutdownComputer } from "./computer/service";
import { shutdownVms, startVms } from "./vm/service";
+import { closeAllConnections } from "./ssh/client";
import { closeGuestTunnels } from "./vm/guest";
import { startTasks, stopTasks } from "./tasks/service";
import { runDoctor } from "./services/doctor";
@@ -214,6 +215,7 @@ async function serve(values: Record) {
await shutdownComputer();
await shutdownVms().catch((err) => log.warn("could not stop VMs", err));
closeGuestTunnels();
+ closeAllConnections();
server.stop(true);
closeDb();
process.exit(0);
diff --git a/packages/core/src/mcp/http.ts b/packages/core/src/mcp/http.ts
index 7a98c65f..a7c4819a 100644
--- a/packages/core/src/mcp/http.ts
+++ b/packages/core/src/mcp/http.ts
@@ -13,6 +13,7 @@ import { resolveRunToken } from "./tokens";
import { UnknownToolError, callTool, listToolsFor, toolErrorMessage } from "./tools";
import { COMPUTER_INSTRUCTIONS, UnknownComputerToolError, callComputerTool, listComputerTools } from "../computer/tools";
import { UnknownVmToolError, VM_INSTRUCTIONS, callVmTool, listVmTools } from "../vm/tools";
+import { SSH_INSTRUCTIONS, UnknownSshToolError, callSshTool, listSshTools } from "../ssh/tools";
const log = logger("mcp");
@@ -52,7 +53,10 @@ function idOf(msg: unknown): JsonRpcId {
const ok = (id: JsonRpcId, result: unknown): JsonRpcResponse => ({ jsonrpc: "2.0", id, result });
const rpcError = (id: JsonRpcId, code: number, message: string): JsonRpcResponse => ({ jsonrpc: "2.0", id, error: { code, message } });
-/** One MCP server behind the gateway: `/mcp` (Godmode tools), `/mcp/computer` (computer use) or `/mcp/vm` (macOS VM). */
+/**
+ * One MCP server behind the gateway: `/mcp` (Godmode tools), `/mcp/computer` (computer use), `/mcp/vm` (macOS VM) or
+ * `/mcp/ssh` (SSH servers).
+ */
export interface McpServerDef {
name: string;
instructions: string;
@@ -85,6 +89,14 @@ export const VM_SERVER: McpServerDef = {
isUnknownTool: (err) => err instanceof UnknownVmToolError,
};
+export const SSH_SERVER: McpServerDef = {
+ name: "ssh",
+ instructions: SSH_INSTRUCTIONS,
+ list: listSshTools,
+ call: callSshTool,
+ isUnknownTool: (err) => err instanceof UnknownSshToolError,
+};
+
/** Handle one JSON-RPC message. Returns null for notifications and client responses (nothing to send). */
export async function handleRpc(ctx: RunContext, msg: unknown, server: McpServerDef = GODMODE_SERVER): Promise {
if (!isObj(msg)) return rpcError(null, -32600, "Invalid Request");
@@ -231,9 +243,10 @@ export function registerMcpRoutes(app: Hono): void {
app.post("/mcp", (c) => serve(c, GODMODE_SERVER));
app.post("/mcp/computer", (c) => serve(c, COMPUTER_SERVER));
app.post("/mcp/vm", (c) => serve(c, VM_SERVER));
+ app.post("/mcp/ssh", (c) => serve(c, SSH_SERVER));
// Stateless servers: no server-initiated SSE stream and no sessions to terminate.
- for (const path of ["/mcp", "/mcp/computer", "/mcp/vm"]) {
+ for (const path of ["/mcp", "/mcp/computer", "/mcp/vm", "/mcp/ssh"]) {
app.get(path, (c) => c.body(null, 405, { Allow: "POST, DELETE" }));
app.delete(path, (c) => c.body(null, 200));
}
diff --git a/packages/core/src/runner/mcpConfig.ts b/packages/core/src/runner/mcpConfig.ts
index a9de256d..1364d592 100644
--- a/packages/core/src/runner/mcpConfig.ts
+++ b/packages/core/src/runner/mcpConfig.ts
@@ -2,14 +2,14 @@
* Builds the `--mcp-config` file for a run: the Godmode gateway (per-run bearer token), the browser
* (browser-use MCP bound to the agent's Chromium profile), computer use (when a screen, window or tab is shared),
* the macOS VM tools (when the run works in a VM β then the browser and computer use run inside the VM too, see
- * vm/guest.ts) and the agent's external MCP servers.
+ * vm/guest.ts), the SSH tools (when the run may use SSH servers) and the agent's external MCP servers.
* The file contains the run token and decrypted MCP secrets, so it is written 0600 and deleted after the run.
*/
import { rmSync, writeFileSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Agent } from "@godmode/shared";
-import { BROWSER_MCP_NAME, COMPUTER_MCP_NAME, CUA_MCP_NAME, GODMODE_MCP_NAME, VM_MCP_NAME } from "@godmode/shared";
+import { BROWSER_MCP_NAME, COMPUTER_MCP_NAME, CUA_MCP_NAME, GODMODE_MCP_NAME, SSH_MCP_NAME, VM_MCP_NAME } from "@godmode/shared";
import { config, isLoopbackHost } from "../config";
import { browserMcpServer } from "../browser/manager";
import { mcpServersForAgent } from "../integrations/mcpServers";
@@ -51,6 +51,8 @@ export async function buildMcpConfig(
browserProfileId?: string | null;
/** The run and its chat: browser tools only reach that chat's tabs. */
run?: { runId: string; conversationId: string };
+ /** The run may use SSH servers. */
+ ssh?: boolean;
} = {},
): Promise {
const servers: Record = {};
@@ -64,7 +66,7 @@ export async function buildMcpConfig(
const external = await mcpServersForAgent(agent);
for (const [name, server] of Object.entries(external)) {
// "cua" is only taken in a VM run (the human may have their own Cua Driver server for runs on this computer).
- if ([GODMODE_MCP_NAME, BROWSER_MCP_NAME, COMPUTER_MCP_NAME, VM_MCP_NAME].includes(name) || (opts.vm && name === CUA_MCP_NAME)) {
+ if ([GODMODE_MCP_NAME, BROWSER_MCP_NAME, COMPUTER_MCP_NAME, VM_MCP_NAME, SSH_MCP_NAME].includes(name) || (opts.vm && name === CUA_MCP_NAME)) {
log.warn(`MCP server name "${name}" is reserved; skipping it for agent ${agent.id}`);
opts.onNotice?.(`The MCP server "${name}" was skipped because its name is reserved by Godmode.`);
continue;
@@ -115,6 +117,15 @@ export async function buildMcpConfig(
if (opts.vm.cua) servers[CUA_MCP_NAME] = guestCuaServer(opts.vm.id, opts.vm.cua);
}
+ // SSH servers: shell, file and transfer tools on the chat's and the agent's servers (scoped by the run token).
+ if (opts.ssh) {
+ servers[SSH_MCP_NAME] = {
+ type: "http",
+ url: `${gatewayUrl()}/ssh`,
+ headers: { Authorization: `Bearer ${runToken}` },
+ };
+ }
+
return { mcpServers: servers };
}
diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts
index 82609bef..f5ed0fda 100644
--- a/packages/core/src/runner/prompt.ts
+++ b/packages/core/src/runner/prompt.ts
@@ -9,6 +9,7 @@ import { join } from "node:path";
import type { Agent, ComputerTarget, Settings } from "@godmode/shared";
import { computerTargetLabel } from "@godmode/shared";
import type { RunSource } from "../services/workspaceSources";
+import type { PromptSshServer } from "../ssh/service";
import { vmSupport } from "../vm/tart";
export interface PromptContext {
@@ -22,6 +23,8 @@ export interface PromptContext {
computer?: ComputerTarget | null;
/** macOS VM this run works in (vm MCP tools). */
vm?: PromptVm | null;
+ /** SSH servers this run may use (ssh MCP tools). */
+ ssh?: PromptSshServer[];
/** The message was dictated β answer in speakable prose. */
voice?: boolean;
/** Folder attached to the chat (Claude's cwd). null = the agent's own repository. */
@@ -170,6 +173,7 @@ No browser tools are attached to this run. If a task needs a website, say so in
if (ctx.apiTools?.length) out.push(apiToolsSection(ctx.apiTools, human));
if (ctx.sources?.items.length) out.push(sourcesSection(ctx.sources, human, !!ctx.vm));
if (ctx.vm) out.push(vmSection(ctx.vm, human, settings.browser.enabled && agent.browser.enabled));
+ if (ctx.ssh?.length) out.push(sshSection(ctx.ssh, human));
if (ctx.computer) out.push(computerSection(ctx.computer, human, perms.secretAccess === "reveal"));
out.push(`### Logging in to websites
@@ -325,6 +329,22 @@ ${
}`;
}
+function sshServerLine(s: PromptSshServer): string {
+ const about = [s.os, s.description ? oneLine(s.description, 300) : ""].filter(Boolean).join(" β ");
+ return `- **${s.name}** β \`${s.address}\` (id \`${s.id}\`)${about ? `: ${about}` : ""}`;
+}
+
+function sshSection(servers: PromptSshServer[], human: string): string {
+ const noSudo = servers.filter((s) => !s.sudoPassword).map((s) => s.name);
+ return `### SSH servers
+${human} gave you access to ${servers.length === 1 ? "this server" : "these servers"} over SSH. Godmode signs in with the saved password or key β you never see them, and you never need them.
+${servers.map(sshServerLine).join("\n")}
+- Use the \`ssh\` MCP tools: \`shell\` runs a command (a new session per call β pass \`cwd\`; nothing may wait for input, so use non-interactive flags), \`read_file\` / \`write_file\` / \`edit_file\` work on text files, \`upload\` / \`download\` copy files between the folders of this run on ${human}'s computer and a server, \`list_servers\` shows them again.${servers.length > 1 ? " Pass `server` (its name) on every call." : ""} Claude Code's own Bash and file tools don't reach these servers.
+- \`sudo: true\` on \`shell\` runs the command as root and Godmode answers sudo's password prompt. Never put a password into a command and never ask ${human} for one.${noSudo.length ? ` No password is saved for ${noSudo.join(", ")}, so sudo only works there if it doesn't ask for one.` : ""}
+- These are real machines, often in production. Look before you change anything, back up a config file before editing it, and don't restart or stop services, reboot, delete data, or change firewall, user or SSH settings unless ${human} asked for exactly that.
+- Start long-running processes in the background (\`nohup β¦ > /tmp/x.log 2>&1 &\`); \`shell\` returns when the command's output closes.`;
+}
+
function computerSection(target: ComputerTarget, human: string, canReveal: boolean): string {
const what = computerTargetLabel(target);
const scope =
@@ -360,9 +380,10 @@ export function resumeContextPrefix(
/** The chat's pending follow-up. */
followup?: { dueAt: string; note: string } | null;
apiTools?: PromptApiTool[];
+ ssh?: PromptSshServer[];
} = {},
): string {
- const { now = new Date(), instructions, memoryChanged, vm, sources, followup, apiTools } = opts;
+ const { now = new Date(), instructions, memoryChanged, vm, sources, followup, apiTools, ssh } = opts;
const where = folder
? `Working directory: \`${folder}\` (the folder attached to this chat). Your own repository with CLAUDE.md and MEMORY.md: \`${repoPath}\`.`
: `Working directory: your own repository \`${repoPath}\`.`;
@@ -385,8 +406,12 @@ export function resumeContextPrefix(
const tools = apiTools?.length
? `\nAPI tools you can use (api_tool_docs, then api_tool_request): ${apiTools.map((t) => `${t.name} (\`${t.id}\`${t.envVar ? `, $${t.envVar}` : ""})`).join(", ")}.`
: "";
+ // SSH servers can be added to or taken from the chat between turns.
+ const remote = ssh?.length
+ ? `\nSSH servers you may use with the \`ssh\` MCP tools (Godmode signs in and answers sudo): ${ssh.map((s) => `${s.name} (\`${s.address}\`)`).join(", ")}.`
+ : "";
const pending = followup
? `\n\nYou scheduled a follow-up in this chat for ${describeNow(new Date(followup.dueAt))}: "${oneLine(followup.note, 300)}". If this message settles or changes that, move it with followup_schedule or remove it with followup_cancel.`
: "";
- return `Current date/time: ${describeNow(now)}\n${where}${attached}${tools}${machine}${update}${memory}${pending} \n\n`;
+ return `Current date/time: ${describeNow(now)}\n${where}${attached}${tools}${machine}${remote}${update}${memory}${pending} \n\n`;
}
diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts
index f6126b4f..d6fd1608 100644
--- a/packages/core/src/runner/runner.ts
+++ b/packages/core/src/runner/runner.ts
@@ -50,6 +50,8 @@ import { attachComputer, computerLockKey, detachComputer } from "../computer/ser
import { attachVm, detachVm, type RunVm } from "../vm/service";
import { CUA_HIDDEN_TOOLS, currentVmPage, prepareGuest, type GuestTools } from "../vm/guest";
import { resolveVmId } from "../vm/assignments";
+import { runSshServerIds } from "../ssh/assignments";
+import { attachSsh, detachSsh, promptServers } from "../ssh/service";
import { parseComputerTarget } from "../computer/targets";
import { StreamAccumulator, detectLoginFailure, redactBlocks } from "./stream";
@@ -995,6 +997,12 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise s.path)].filter((f): f is string => !!f);
+ attachSsh(job.runId, [...new Set(folders)]);
+ }
const mcp = await buildMcpConfig(agent, res.token, {
onNotice: (text) => job.acc.addNotice("warning", text),
computer: !!computer,
@@ -1002,6 +1010,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise 0,
});
const mcpPath = writeMcpConfigFile(job.runId, mcp);
res.files.push(mcpPath);
@@ -1045,6 +1054,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise("SELECT due_at AS dueAt, note FROM followups WHERE conversation_id = ?", job.conversationId);
const prompt =
resuming && !command
- ? resumeContextPrefix(folder, agent.repoPath, { instructions: restate ? standing : undefined, memoryChanged, vm: promptVm, sources: promptSources, followup, apiTools }) +
+ ? resumeContextPrefix(folder, agent.repoPath, { instructions: restate ? standing : undefined, memoryChanged, vm: promptVm, sources: promptSources, followup, apiTools, ssh }) +
job.prompt
: job.prompt;
let attempt = await spawnClaude(job, cmd, [...baseArgs, ...sessionArgs, ...extraArgs], prompt, cwd, env, logSink);
@@ -1233,6 +1243,7 @@ async function execute(job: Job): Promise {
for (const f of res.files) removeMcpConfigFile(f);
await detachComputer(job.runId).catch(() => {});
detachVm(job.runId);
+ detachSsh(job.runId);
}
// Always push the final streamed state (a throttled delta may still be pending).
if (job.status === "running") safely("emit final delta", () => emitDelta(job));
diff --git a/packages/core/src/server/app.ts b/packages/core/src/server/app.ts
index 125e6f16..28f03fa0 100644
--- a/packages/core/src/server/app.ts
+++ b/packages/core/src/server/app.ts
@@ -19,6 +19,7 @@ import { registerFolderRoutes } from "./routes/folders";
import { registerMcpRoutes } from "../mcp/http";
import { registerComputerRoutes } from "./routes/computer";
import { registerVmRoutes } from "./routes/vms";
+import { registerSshRoutes } from "./routes/ssh";
import { registerLogRoutes } from "./routes/logs";
import { registerTaskRoutes } from "./routes/tasks";
import { serveStatic } from "./static";
@@ -144,6 +145,7 @@ export function createApp() {
registerComputerRoutes(app);
registerVmRoutes(app);
registerTaskRoutes(app);
+ registerSshRoutes(app);
registerBackupRoutes(app);
registerVoiceRoutes(app);
registerFolderRoutes(app);
diff --git a/packages/core/src/server/routes/agents.ts b/packages/core/src/server/routes/agents.ts
index 79b2cb0d..ca323409 100644
--- a/packages/core/src/server/routes/agents.ts
+++ b/packages/core/src/server/routes/agents.ts
@@ -91,6 +91,7 @@ export const agentSchema = z.object({
subagents: z.array(subagentSchema).max(20).optional(),
workingDirectory: z.string().trim().max(4096).nullable().optional(),
vmId: id.nullable().optional(),
+ sshServerIds: z.array(id).max(50).optional(),
});
const triggerSchema = z.discriminatedUnion("type", [
diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts
index b293dbfc..15b05fa5 100644
--- a/packages/core/src/server/routes/chat.ts
+++ b/packages/core/src/server/routes/chat.ts
@@ -43,6 +43,8 @@ const vmId = z.string().trim().max(100).nullable().optional();
const browserProfileId = z.string().trim().max(100).nullable().optional();
/** Workspace the chat is started in; a global agent browses with its default profile. */
const workspaceId = z.string().trim().max(100).nullable().optional();
+/** SSH servers of the chat (the whole list); the agent's apply anyway. */
+const sshServerIds = z.array(z.string().trim().min(1).max(100)).max(50).optional();
const sendSchema = z.object({
content: z.string().max(200_000).default(""),
@@ -74,7 +76,10 @@ export function registerChatRoutes(app: Hono): void {
);
app.post("/api/conversations", async (c) => {
- const input = await body(c, z.object({ agentId: z.string().min(1), title: z.string().max(200).optional(), workingDirectory: folder, vmId, browserProfileId, workspaceId, instructions, ...modelChoice }));
+ const input = await body(
+ c,
+ z.object({ agentId: z.string().min(1), title: z.string().max(200).optional(), workingDirectory: folder, vmId, browserProfileId, workspaceId, sshServerIds, instructions, ...modelChoice }),
+ );
return c.json(createConversation({ ...input, origin: "chat" }), 201);
});
@@ -92,6 +97,7 @@ export function registerChatRoutes(app: Hono): void {
computerTarget: computerTargetSchema.nullable().optional(),
vmId,
browserProfileId,
+ sshServerIds,
instructions,
}),
);
@@ -136,6 +142,7 @@ export function registerChatRoutes(app: Hono): void {
vmId,
browserProfileId,
workspaceId,
+ sshServerIds,
instructions,
...modelChoice,
}),
diff --git a/packages/core/src/server/routes/ssh.ts b/packages/core/src/server/routes/ssh.ts
new file mode 100644
index 00000000..9fca7f13
--- /dev/null
+++ b/packages/core/src/server/routes/ssh.ts
@@ -0,0 +1,71 @@
+import type { Hono } from "hono";
+import { generateKeyPair, listLocalKeys } from "../../ssh/keys";
+import { assignServer, createServer, deleteServer, execForHuman, getServer, listServers, testServer, tryServer, updateServer } from "../../ssh/service";
+import { disableIdleTimeout } from "../../mcp/http";
+import { body, z } from "../validate";
+
+const hostKey = z.object({ type: z.string().trim().max(60), fingerprint: z.string().trim().max(100) });
+
+const fields = {
+ name: z.string().max(100),
+ host: z.string().max(300),
+ port: z.number().int().min(1).max(65535).optional(),
+ username: z.string().max(100),
+ auth: z.enum(["password", "key"]),
+ password: z.string().max(1000).optional(),
+ privateKey: z.string().max(64 * 1024).optional(),
+ privateKeyPath: z.string().max(4096).optional(),
+ passphrase: z.string().max(1000).optional(),
+ description: z.string().max(4000).optional(),
+ hostKey: hostKey.nullable().optional(),
+};
+
+const serverSchema = z.object(fields);
+const patchSchema = z.object(fields).partial();
+
+export function registerSshRoutes(app: Hono): void {
+ app.get("/api/ssh/servers", (c) => c.json(listServers()));
+
+ app.post("/api/ssh/servers", async (c) => c.json(createServer(await body(c, serverSchema)), 201));
+
+ app.get("/api/ssh/servers/:id", (c) => c.json(getServer(c.req.param("id"))));
+
+ app.patch("/api/ssh/servers/:id", async (c) => c.json(updateServer(c.req.param("id"), await body(c, patchSchema))));
+
+ app.delete("/api/ssh/servers/:id", (c) => {
+ deleteServer(c.req.param("id"));
+ return c.json({ ok: true as const });
+ });
+
+ /** Sign in to a saved server (pins its host key the first time). */
+ app.post("/api/ssh/servers/:id/test", async (c) => {
+ disableIdleTimeout(c);
+ return c.json(await testServer(c.req.param("id")));
+ });
+
+ /** Try settings before saving them; `id` fills in the secrets of a saved server. */
+ app.post("/api/ssh/test", async (c) => {
+ const input = await body(c, serverSchema.extend({ id: z.string().max(100).optional() }));
+ disableIdleTimeout(c);
+ return c.json(await tryServer(input));
+ });
+
+ app.post("/api/ssh/servers/:id/exec", async (c) => {
+ const input = await body(c, z.object({ command: z.string().min(1).max(100_000), timeoutSeconds: z.number().int().min(1).max(600).optional() }));
+ disableIdleTimeout(c);
+ return c.json(await execForHuman(c.req.param("id"), input));
+ });
+
+ app.post("/api/ssh/servers/:id/assign", async (c) => {
+ const input = await body(c, z.object({ kind: z.enum(["agent", "conversation"]), id: z.string().min(1).max(100), assigned: z.boolean() }));
+ return c.json(await assignServer(c.req.param("id"), input));
+ });
+
+ /** Private keys in ~/.ssh on the computer running Godmode (to import one without copying it around). */
+ app.get("/api/ssh/local-keys", (c) => c.json(listLocalKeys()));
+
+ app.post("/api/ssh/keys", async (c) => {
+ const input = await body(c, z.object({ comment: z.string().trim().max(100).optional() }));
+ return c.json(generateKeyPair(input.comment || "godmode"));
+ });
+}
diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts
index a4fa71f4..67ae7830 100644
--- a/packages/core/src/services/conversations.ts
+++ b/packages/core/src/services/conversations.ts
@@ -24,6 +24,7 @@ import { bus } from "../events/bus";
import { logger } from "../log";
import { badRequest, conflict, newId, notFound, now, parseJson } from "../util";
import { assignmentsChanged, normalizeVmId } from "../vm/assignments";
+import { normalizeSshServerIds, parseServerIds } from "../ssh/assignments";
import { redact } from "../vault/vault";
import { getAgent, getDefaultAgentId } from "../agents/service";
import { activeRunForConversation, cancelRun, listActiveRuns, retryQueued, startRun, waitForRun } from "../runner/runner";
@@ -54,6 +55,7 @@ interface ConversationRow {
vm_id: string | null;
browser_profile_id: string | null;
workspace_id: string | null;
+ ssh_server_ids: string | null;
instructions: string;
pinned: number;
archived: number;
@@ -112,6 +114,7 @@ function toConversation(r: ConversationRow): Conversation {
vmId: r.vm_id ?? null,
browserProfileId: r.browser_profile_id ?? null,
workspaceId: r.workspace_id ?? null,
+ sshServerIds: parseServerIds(r.ssh_server_ids),
instructions: r.instructions,
pinned: bool(r.pinned),
archived: bool(r.archived),
@@ -209,6 +212,7 @@ export function createConversation(
vmId?: string | null;
browserProfileId?: string | null;
workspaceId?: string | null;
+ sshServerIds?: string[];
instructions?: string;
} & ModelChoice,
): Conversation {
@@ -216,6 +220,7 @@ export function createConversation(
const workingDirectory = normalizeWorkingDirectory(input.workingDirectory);
const vmId = normalizeVmId(input.vmId) ?? null;
const browserProfileId = normalizeBrowserProfileId(input.browserProfileId) ?? null;
+ const sshServerIds = normalizeSshServerIds(input.sshServerIds) ?? [];
const ts = now();
const id = newId("cnv");
const title = input.title?.trim() ? input.title.trim().slice(0, 200) : DEFAULT_CONVERSATION_TITLE;
@@ -231,6 +236,7 @@ export function createConversation(
vm_id: vmId,
browser_profile_id: browserProfileId,
workspace_id: normalizeWorkspaceId(agent, input.workspaceId),
+ ssh_server_ids: JSON.stringify(sshServerIds),
instructions: input.instructions?.trim() ?? "",
pinned: 0,
archived: 0,
@@ -288,6 +294,7 @@ export function updateConversation(id: string, patch: ConversationPatch): Conver
computer_target: patch.computerTarget === undefined ? undefined : patch.computerTarget ? JSON.stringify(parseComputerTarget(patch.computerTarget)) : null,
vm_id: normalizeVmId(patch.vmId),
browser_profile_id: normalizeBrowserProfileId(patch.browserProfileId),
+ ssh_server_ids: patch.sshServerIds === undefined ? undefined : JSON.stringify(normalizeSshServerIds(patch.sshServerIds)),
instructions: patch.instructions?.trim(),
updated_at: now(),
});
@@ -297,6 +304,7 @@ export function updateConversation(id: string, patch: ConversationPatch): Conver
if (patch.browserProfileId !== undefined) retryQueued();
// Archived, or moved to another browser profile: its tabs aren't needed where they are.
if (patch.archived || patch.browserProfileId !== undefined) void closeChatTabs(id);
+ if (patch.sshServerIds !== undefined || (patch.archived !== undefined && conversation.sshServerIds.length)) bus.changed("ssh-servers");
return conversation;
}
@@ -347,6 +355,7 @@ export async function deleteConversation(id: string): Promise {
}
await closeChatTabs(id);
bus.emit({ type: "conversation.deleted", id });
+ if (parseServerIds(row.ssh_server_ids).length) bus.changed("ssh-servers");
}
/* ------------------------------------------------------------------ */
@@ -541,6 +550,8 @@ export async function startChat(
browserProfileId?: string | null;
/** Workspace the chat is started in (the sidebar's); a global agent browses with its default profile. */
workspaceId?: string | null;
+ /** SSH servers for this chat, in addition to the agent's. */
+ sshServerIds?: string[];
instructions?: string;
} & ModelChoice,
): Promise {
@@ -559,6 +570,7 @@ export async function startChat(
vmId: input.vmId,
browserProfileId: input.browserProfileId,
workspaceId: input.workspaceId,
+ sshServerIds: input.sshServerIds,
instructions: input.instructions,
model: input.model,
effort: input.effort,
diff --git a/packages/core/src/ssh/assignments.ts b/packages/core/src/ssh/assignments.ts
new file mode 100644
index 00000000..fd3e3d92
--- /dev/null
+++ b/packages/core/src/ssh/assignments.ts
@@ -0,0 +1,60 @@
+/**
+ * Which SSH servers an agent or chat uses (`ssh_server_ids` columns, JSON arrays). Kept free of service imports so the
+ * agent and conversation services and the runner can use it without depending on the SSH service.
+ */
+import type { SshAssignment } from "@godmode/shared";
+import { all, get, run } from "../db";
+import { parseJson } from "../util";
+
+/** Every SSH server id that exists, of the given ones. */
+function existing(ids: string[]): Set {
+ if (!ids.length) return new Set();
+ return new Set(all<{ id: string }>(`SELECT id FROM ssh_servers WHERE id IN (${ids.map(() => "?").join(", ")})`, ...ids).map((r) => r.id));
+}
+
+export function parseServerIds(value: string | null | undefined): string[] {
+ const list = parseJson(value, []);
+ return Array.isArray(list) ? [...new Set(list.filter((x): x is string => typeof x === "string" && x.length > 0))] : [];
+}
+
+/**
+ * Normalize a list from an API input: undefined = unchanged, else the ids of servers that exist (a server deleted while
+ * a screen still showed it is dropped).
+ */
+export function normalizeSshServerIds(value: string[] | null | undefined): string[] | undefined {
+ if (value === undefined) return undefined;
+ const ids = [...new Set((value ?? []).map((v) => v.trim()).filter(Boolean))];
+ const found = existing(ids);
+ return ids.filter((id) => found.has(id));
+}
+
+/** The servers a run may use: its chat's and its agent's, in that order (deleted ones are skipped). */
+export function runSshServerIds(conversationId: string | null, agentId: string): string[] {
+ const conv = conversationId ? get<{ ssh_server_ids: string | null }>("SELECT ssh_server_ids FROM conversations WHERE id = ?", conversationId) : null;
+ const agent = get<{ ssh_server_ids: string | null }>("SELECT ssh_server_ids FROM agents WHERE id = ?", agentId);
+ const ids = [...new Set([...parseServerIds(conv?.ssh_server_ids), ...parseServerIds(agent?.ssh_server_ids)])];
+ const found = existing(ids);
+ return ids.filter((id) => found.has(id));
+}
+
+/** Agents and chats (not archived) that use the server. */
+export function sshAssignments(serverId: string): SshAssignment[] {
+ const uses = "EXISTS (SELECT 1 FROM json_each(ssh_server_ids) WHERE value = ?)";
+ const agents = all<{ id: string; name: string }>(`SELECT id, name FROM agents WHERE ${uses} ORDER BY name COLLATE NOCASE`, serverId);
+ const chats = all<{ id: string; title: string }>(
+ `SELECT id, title FROM conversations WHERE archived = 0 AND ${uses} ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT 50`,
+ serverId,
+ );
+ return [...agents.map((r) => ({ kind: "agent" as const, id: r.id, name: r.name })), ...chats.map((r) => ({ kind: "conversation" as const, id: r.id, name: r.title }))];
+}
+
+/** A deleted server leaves every agent and chat (call inside the delete's transaction). */
+export function removeServerEverywhere(serverId: string): { agents: string[]; conversations: string[] } {
+ const uses = "EXISTS (SELECT 1 FROM json_each(ssh_server_ids) WHERE value = ?)";
+ const without = "COALESCE((SELECT json_group_array(value) FROM json_each(ssh_server_ids) WHERE value <> ?), '[]')";
+ const agents = all<{ id: string }>(`SELECT id FROM agents WHERE ${uses}`, serverId).map((r) => r.id);
+ const conversations = all<{ id: string }>(`SELECT id FROM conversations WHERE ${uses}`, serverId).map((r) => r.id);
+ run(`UPDATE agents SET ssh_server_ids = ${without} WHERE ${uses}`, serverId, serverId);
+ run(`UPDATE conversations SET ssh_server_ids = ${without} WHERE ${uses}`, serverId, serverId);
+ return { agents, conversations };
+}
diff --git a/packages/core/src/ssh/client.ts b/packages/core/src/ssh/client.ts
new file mode 100644
index 00000000..088ea939
--- /dev/null
+++ b/packages/core/src/ssh/client.ts
@@ -0,0 +1,650 @@
+/**
+ * SSH connections with ssh2 (pure JavaScript, so it works in the compiled core on every platform). One pooled
+ * connection per server is shared by every run and the human's "Run command"; it closes after a few idle minutes or
+ * when the server's settings change. The host key is pinned on the first connection (like OpenSSH's
+ * `StrictHostKeyChecking=accept-new`): a server that later presents another key is refused.
+ */
+import { posix } from "node:path";
+import { Client, type ConnectConfig, type SFTPWrapper, type Stats } from "ssh2";
+import type { SshAuthMethod, SshHostKey, SshTestStage } from "@godmode/shared";
+import { logger } from "../log";
+import { blobType, fingerprintOf } from "./keys";
+
+const log = logger("ssh");
+
+const READY_TIMEOUT_MS = 20_000;
+const IDLE_CLOSE_MS = 3 * 60_000;
+/** OpenSSH allows 10 sessions per connection by default; stay below it. */
+const MAX_CHANNELS = 6;
+/** Per stream, what a command's output keeps in memory: the start and the end. */
+const CAPTURE_BYTES = 512 * 1024;
+
+export interface ConnectTarget {
+ host: string;
+ port: number;
+ username: string;
+ auth: SshAuthMethod;
+ password: string | null;
+ privateKey: string | null;
+ passphrase: string | null;
+ /** Pinned host key; null = trust the first one the server presents. */
+ hostKey: SshHostKey | null;
+}
+
+/** A connection problem in words for the human, with the step that failed. */
+export class SshError extends Error {
+ constructor(
+ message: string,
+ readonly stage: SshTestStage,
+ readonly hostKey: SshHostKey | null = null,
+ readonly hostKeyChanged = false,
+ ) {
+ super(message);
+ }
+}
+
+export interface Session {
+ client: Client;
+ /** The key the server presented. */
+ hostKey: SshHostKey;
+ /** Connect, handshake and sign-in. */
+ latencyMs: number;
+}
+
+export function addressOf(t: Pick): string {
+ return t.port === 22 ? t.host : `${t.host}:${t.port}`;
+}
+
+function translate(err: unknown, t: ConnectTarget, seen: SshHostKey | null, changed: boolean): SshError {
+ const e = err as Error & { code?: string; level?: string };
+ const where = addressOf(t);
+ if (changed && t.hostKey && seen) {
+ return new SshError(
+ `The host key of ${where} changed: Godmode trusts ${t.hostKey.fingerprint}, the server now shows ${seen.fingerprint}. ` +
+ "That happens when a server is reinstalled β or when someone intercepts the connection. If the change is expected, forget the saved host key in SSH servers and connect again.",
+ "host-key",
+ seen,
+ true,
+ );
+ }
+ switch (e.code) {
+ case "ENOTFOUND":
+ case "EAI_AGAIN":
+ return new SshError(`Can't find the host "${t.host}" β check the name (the DNS lookup failed).`, "connect");
+ case "ECONNREFUSED":
+ return new SshError(`${where} refused the connection β is SSH running on port ${t.port}?`, "connect");
+ case "ETIMEDOUT":
+ return new SshError(`No answer from ${where} β check the address, the port and the firewall.`, "connect");
+ case "EHOSTUNREACH":
+ case "ENETUNREACH":
+ return new SshError(`${where} can't be reached from this computer (no route to the host).`, "connect");
+ case "ECONNRESET":
+ return new SshError(`${where} closed the connection before the sign-in β it may block this computer, or not speak SSH on port ${t.port}.`, "connect");
+ }
+ const message = e.message || String(err);
+ if (/timed out while waiting for handshake/i.test(message)) {
+ return new SshError(`No SSH answer from ${where} within ${READY_TIMEOUT_MS / 1000} seconds β check the address, the port and the firewall.`, "connect");
+ }
+ if (/all configured authentication methods failed/i.test(message)) {
+ return new SshError(
+ t.auth === "key"
+ ? `${where} didn't accept the key for "${t.username}". Check the user name, and that the public key is in ~/.ssh/authorized_keys on the server.`
+ : `${where} didn't accept the password for "${t.username}".`,
+ "auth",
+ seen,
+ );
+ }
+ if (/privateKey|passphrase/i.test(message)) return new SshError(`The saved private key can't be used: ${message.replace(/^Cannot parse privateKey: /i, "")}`, "config", seen);
+ if (e.level === "client-socket") return new SshError(`The connection to ${where} failed: ${message}`, "connect");
+ return new SshError(`SSH to ${where} failed: ${message}`, seen ? "auth" : "connect", seen);
+}
+
+/** Connect and sign in. */
+export function connect(target: ConnectTarget, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) return reject(new SshError("Cancelled", "connect"));
+ const client = new Client();
+ client.setMaxListeners(4 * MAX_CHANNELS + 10);
+ const started = performance.now();
+ let seen: SshHostKey | null = null;
+ let changed = false;
+ let settled = false;
+ const onAbort = () => fail(new SshError("Cancelled", "connect"));
+ const fail = (err: SshError) => {
+ if (settled) return;
+ settled = true;
+ signal?.removeEventListener("abort", onAbort);
+ client.end();
+ reject(err);
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ client.on("ready", () => {
+ if (settled) return void client.end();
+ settled = true;
+ signal?.removeEventListener("abort", onAbort);
+ resolve({ client, hostKey: seen!, latencyMs: Math.round(performance.now() - started) });
+ });
+ client.on("error", (err) => fail(translate(err, target, seen, changed)));
+ client.on("close", () => fail(new SshError(`The connection to ${addressOf(target)} closed during the sign-in.`, seen ? "auth" : "connect", seen)));
+ // Servers that ask for the password through keyboard-interactive (PAM) get it for password prompts only.
+ client.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
+ finish(prompts.map((p) => (target.password && /pass(?:word|code|phrase)?|kennwort|mot de passe|contraseΓ±a/i.test(p.prompt) ? target.password : "")));
+ });
+ const config: ConnectConfig = {
+ host: target.host,
+ port: target.port,
+ username: target.username,
+ readyTimeout: READY_TIMEOUT_MS,
+ keepaliveInterval: 15_000,
+ keepaliveCountMax: 4,
+ hostVerifier: (key: Buffer) => {
+ seen = { type: blobType(key), fingerprint: fingerprintOf(key) };
+ changed = !!target.hostKey && target.hostKey.fingerprint !== seen.fingerprint;
+ return !changed;
+ },
+ };
+ if (target.auth === "key") {
+ config.privateKey = target.privateKey ?? "";
+ if (target.passphrase) config.passphrase = target.passphrase;
+ } else {
+ config.password = target.password ?? "";
+ config.tryKeyboard = true;
+ }
+ try {
+ client.connect(config);
+ } catch (err) {
+ fail(translate(err, target, seen, changed));
+ }
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Commands */
+/* ------------------------------------------------------------------ */
+
+/** Keeps the start and the end of a stream (the middle of huge output is dropped). */
+class Capture {
+ private head: Buffer[] = [];
+ private headBytes = 0;
+ private tail: Buffer[] = [];
+ private tailBytes = 0;
+ total = 0;
+
+ constructor(private readonly max = CAPTURE_BYTES) {}
+
+ push(chunk: Buffer) {
+ this.total += chunk.length;
+ if (this.headBytes < this.max) {
+ const take = chunk.subarray(0, this.max - this.headBytes);
+ this.head.push(take);
+ this.headBytes += take.length;
+ chunk = chunk.subarray(take.length);
+ }
+ if (!chunk.length) return;
+ this.tail.push(chunk);
+ this.tailBytes += chunk.length;
+ while (this.tailBytes - (this.tail[0]?.length ?? 0) >= this.max) this.tailBytes -= this.tail.shift()!.length;
+ }
+
+ text(): string {
+ const head = Buffer.concat(this.head).toString("utf8");
+ if (!this.tail.length) return head;
+ const tail = Buffer.concat(this.tail);
+ const dropped = this.total - this.headBytes - tail.length;
+ return dropped > 0 ? `${head}\n\nβ¦ [${dropped.toLocaleString("en-US")} bytes omitted] β¦\n\n${tail.toString("utf8")}` : head + tail.toString("utf8");
+ }
+}
+
+export interface ExecOptions {
+ stdin?: string | Buffer | null;
+ timeoutMs: number;
+ signal?: AbortSignal;
+ /**
+ * Answer a prompt the command prints to stderr (sudo's, with this marker as its prompt) β only once it is asked for,
+ * so the answer never ends up as input for the command itself. stdin follows the answer, or goes out right away
+ * when the command prints output first or `waitMs` passes without a prompt.
+ */
+ prompt?: { marker: string; answer: string; waitMs: number };
+}
+
+export interface ExecOutcome {
+ exitCode: number | null;
+ /** Set when the command was ended by a signal. */
+ exitSignal: string | null;
+ stdout: string;
+ stderr: string;
+ timedOut: boolean;
+ cancelled: boolean;
+ /** The connection dropped while the command ran. */
+ lost: boolean;
+ /** How often the `prompt` marker appeared (more than once: the answer was rejected). */
+ prompts: number;
+ durationMs: number;
+}
+
+/** POSIX shell quoting (`~` and `~/β¦` keep pointing at the home folder). */
+export function shellQuote(s: string): string {
+ return `'${s.replace(/'/g, `'\\''`)}'`;
+}
+
+export function shellPath(path: string): string {
+ if (path === "~") return '"$HOME"';
+ if (path.startsWith("~/")) return `"$HOME"/${shellQuote(path.slice(2))}`;
+ return shellQuote(path);
+}
+
+/** Run one command (in the user's shell, like `ssh host command`); stdin is sent and closed. */
+export function execOn(client: Client, command: string, opts: ExecOptions): Promise {
+ return new Promise((resolve, reject) => {
+ const started = performance.now();
+ if (opts.signal?.aborted) {
+ return resolve({ exitCode: null, exitSignal: null, stdout: "", stderr: "", timedOut: false, cancelled: true, lost: false, prompts: 0, durationMs: 0 });
+ }
+ client.exec(command, (err, stream) => {
+ if (err) return reject(new SshError(`The command couldn't start: ${err.message}`, "command"));
+ const out = new Capture();
+ const errOut = new Capture();
+ let exitCode: number | null = null;
+ let exitSignal: string | null = null;
+ let timedOut = false;
+ let cancelled = false;
+ let lost = false;
+ let done = false;
+ let prompts = 0;
+ let inputSent = false;
+ let promptTail = "";
+ let promptTimer: ReturnType | null = null;
+ const sendInput = (answer?: string) => {
+ if (inputSent) return;
+ inputSent = true;
+ if (promptTimer) clearTimeout(promptTimer);
+ try {
+ if (answer !== undefined) stream.write(`${answer}\n`);
+ stream.end(opts.stdin ?? "");
+ } catch {
+ /* the command already ended */
+ }
+ };
+ const stop = () => {
+ try {
+ stream.signal("KILL");
+ } catch {
+ /* not supported by every server */
+ }
+ stream.close();
+ };
+ const timer = setTimeout(() => {
+ timedOut = true;
+ stop();
+ }, opts.timeoutMs);
+ const onAbort = () => {
+ cancelled = true;
+ stop();
+ };
+ const finish = () => {
+ if (done) return;
+ done = true;
+ clearTimeout(timer);
+ if (promptTimer) clearTimeout(promptTimer);
+ opts.signal?.removeEventListener("abort", onAbort);
+ client.removeListener("close", onLost);
+ const stderr = opts.prompt ? errOut.text().split(opts.prompt.marker).join("") : errOut.text();
+ resolve({ exitCode, exitSignal, stdout: out.text(), stderr, timedOut, cancelled, lost, prompts, durationMs: Math.round(performance.now() - started) });
+ };
+ const onLost = () => {
+ lost = exitCode === null && exitSignal === null;
+ finish();
+ };
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
+ client.once("close", onLost);
+ const prompt = opts.prompt;
+ stream.on("data", (d: Buffer) => {
+ out.push(d);
+ // Output before any prompt: nothing will be asked.
+ if (prompt) sendInput();
+ });
+ stream.stderr.on("data", (d: Buffer) => {
+ errOut.push(d);
+ if (!prompt) return;
+ const seen = promptTail + d.toString("utf8");
+ const found = seen.split(prompt.marker).length - 1;
+ promptTail = seen.slice(-prompt.marker.length);
+ if (!found) return;
+ prompts += found;
+ // Only the first prompt is answered: a second one means the answer was wrong.
+ if (prompts === found) sendInput(prompt.answer);
+ else sendInput();
+ });
+ stream.on("exit", (code: number | null, sig?: string) => {
+ exitCode = typeof code === "number" ? code : null;
+ exitSignal = sig ?? null;
+ });
+ stream.on("close", finish);
+ stream.on("error", finish);
+ if (prompt) {
+ promptTimer = setTimeout(() => sendInput(), prompt.waitMs);
+ promptTimer.unref?.();
+ } else sendInput();
+ });
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Pool */
+/* ------------------------------------------------------------------ */
+
+export interface PoolOptions {
+ /** Changes whenever the server's settings change; a connection made for other settings isn't reused. */
+ version: string;
+ target: () => ConnectTarget;
+ /** A new connection signed in to `target` (pin the host key, remember when). */
+ onConnect?: (session: Session, target: ConnectTarget) => void;
+ onError?: (err: SshError) => void;
+}
+
+class Pooled {
+ users = 0;
+ channels = 0;
+ waiting: (() => void)[] = [];
+ idle: ReturnType | null = null;
+ retired = false;
+ closed = false;
+ private sftpSession: Promise | null = null;
+
+ constructor(
+ readonly id: string,
+ readonly version: string,
+ readonly session: Promise,
+ ) {}
+
+ async channel(fn: (client: Client) => Promise): Promise {
+ const { client } = await this.session;
+ while (this.channels >= MAX_CHANNELS) await new Promise((r) => this.waiting.push(r));
+ this.channels++;
+ try {
+ return await fn(client);
+ } finally {
+ this.channels--;
+ this.waiting.shift()?.();
+ }
+ }
+
+ async sftp(): Promise {
+ const { client } = await this.session;
+ this.sftpSession ??= new Promise((resolve, reject) =>
+ client.sftp((err, sftp) => {
+ if (err) return reject(new SshError(`SFTP isn't available on this server (${err.message}).`, "command"));
+ sftp.on("close", () => (this.sftpSession = null));
+ resolve(sftp);
+ }),
+ );
+ this.sftpSession.catch(() => (this.sftpSession = null));
+ return this.sftpSession;
+ }
+
+ close() {
+ if (this.closed) return;
+ this.closed = true;
+ if (this.idle) clearTimeout(this.idle);
+ this.session.then((s) => s.client.end()).catch(() => undefined);
+ }
+}
+
+const pool = new Map();
+
+function retire(entry: Pooled) {
+ entry.retired = true;
+ if (pool.get(entry.id) === entry) pool.delete(entry.id);
+ if (entry.users === 0) entry.close();
+}
+
+function open(id: string, opts: PoolOptions): Pooled {
+ const target = opts.target();
+ const session = connect(target);
+ const entry = new Pooled(id, opts.version, session);
+ pool.set(id, entry);
+ session.then(
+ (s) => {
+ log.debug("connected", { server: id, ms: s.latencyMs });
+ s.client.on("close", () => {
+ entry.closed = true;
+ if (pool.get(id) === entry) pool.delete(id);
+ });
+ s.client.on("error", (err) => log.debug("connection error", { server: id, error: err.message }));
+ opts.onConnect?.(s, target);
+ },
+ (err: unknown) => {
+ if (pool.get(id) === entry) pool.delete(id);
+ entry.closed = true;
+ if (err instanceof SshError) opts.onError?.(err);
+ },
+ );
+ return entry;
+}
+
+/** A connection to the server (pooled), for the duration of `fn`. */
+export async function withConnection(id: string, opts: PoolOptions, fn: (conn: Connection) => Promise, signal?: AbortSignal): Promise {
+ let entry = pool.get(id);
+ if (entry && (entry.version !== opts.version || entry.closed)) {
+ retire(entry);
+ entry = undefined;
+ }
+ entry ??= open(id, opts);
+ entry.users++;
+ if (entry.idle) {
+ clearTimeout(entry.idle);
+ entry.idle = null;
+ }
+ const current = entry;
+ try {
+ if (signal) {
+ await new Promise((resolve, reject) => {
+ if (signal.aborted) return reject(new SshError("Cancelled", "connect"));
+ const onAbort = () => reject(new SshError("Cancelled", "connect"));
+ signal.addEventListener("abort", onAbort, { once: true });
+ current.session.then(
+ () => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ },
+ (err) => {
+ signal.removeEventListener("abort", onAbort);
+ reject(err);
+ },
+ );
+ });
+ } else await current.session;
+ return await fn(new Connection(current, signal));
+ } finally {
+ current.users--;
+ if (current.users === 0) {
+ if (current.retired || current.closed) current.close();
+ else {
+ current.idle = setTimeout(() => retire(current), IDLE_CLOSE_MS);
+ current.idle.unref?.();
+ }
+ }
+ }
+}
+
+/** Close the server's pooled connection (settings changed, server deleted); commands still running finish first. */
+export function dropConnection(id: string): void {
+ const entry = pool.get(id);
+ if (entry) retire(entry);
+}
+
+export function closeAllConnections(): void {
+ for (const entry of [...pool.values()]) {
+ retire(entry);
+ entry.close();
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Files (SFTP) */
+/* ------------------------------------------------------------------ */
+
+/** SFTP resolves relative paths against the home folder; `~/x` means the same. */
+export function sftpPath(path: string): string {
+ const p = path.trim();
+ if (p === "~") return ".";
+ if (p.startsWith("~/")) return p.slice(2) || ".";
+ return p;
+}
+
+const SFTP_NO_SUCH_FILE = 2;
+const SFTP_PERMISSION_DENIED = 3;
+
+/** An SFTP failure in words for the model. */
+export class RemoteFileError extends Error {}
+
+function fileError(err: unknown, path: string, verb: string): RemoteFileError {
+ const code = (err as { code?: number }).code;
+ if (code === SFTP_NO_SUCH_FILE) return new RemoteFileError(`No such file or folder: ${path}`);
+ if (code === SFTP_PERMISSION_DENIED) {
+ return new RemoteFileError(`Permission denied: can't ${verb} ${path}. For files owned by root, use shell with sudo: true (e.g. \`cat\`, or \`tee\` with stdin).`);
+ }
+ return new RemoteFileError(`Couldn't ${verb} ${path}: ${err instanceof Error ? err.message : String(err)}`);
+}
+
+function stat(sftp: SFTPWrapper, path: string): Promise {
+ return new Promise((resolve, reject) => sftp.stat(path, (err, st) => (err ? reject(err) : resolve(st))));
+}
+
+async function mkdirp(sftp: SFTPWrapper, dir: string): Promise {
+ if (!dir || dir === "." || dir === "/") return;
+ try {
+ const st = await stat(sftp, dir);
+ if (st.isDirectory()) return;
+ throw new RemoteFileError(`${dir} exists but isn't a folder.`);
+ } catch (err) {
+ if (err instanceof RemoteFileError) throw err;
+ if ((err as { code?: number }).code !== SFTP_NO_SUCH_FILE) throw fileError(err, dir, "open");
+ }
+ await mkdirp(sftp, posix.dirname(dir));
+ await new Promise((resolve, reject) =>
+ sftp.mkdir(dir, (err) => {
+ if (!err) return resolve();
+ stat(sftp, dir).then(
+ (st) => (st.isDirectory() ? resolve() : reject(fileError(err, dir, "create"))),
+ () => reject(fileError(err, dir, "create")),
+ );
+ }),
+ );
+}
+
+/** One signed-in connection to a server, for the duration of `withConnection`. */
+export class Connection {
+ constructor(
+ private readonly entry: Pooled,
+ readonly signal?: AbortSignal,
+ ) {}
+
+ async hostKey(): Promise {
+ return (await this.entry.session).hostKey;
+ }
+
+ exec(command: string, opts: Omit): Promise {
+ return this.entry.channel((client) => execOn(client, command, { ...opts, signal: this.signal }));
+ }
+
+ private sftpCall(fn: (sftp: SFTPWrapper) => Promise): Promise {
+ const cancelled = () => {
+ if (this.signal?.aborted) throw new SshError("Cancelled", "command");
+ };
+ cancelled();
+ return this.entry.channel(async () => {
+ cancelled();
+ return fn(await this.entry.sftp());
+ });
+ }
+
+ /** SFTP works on this server (some turn the subsystem off). */
+ async hasSftp(): Promise {
+ try {
+ await this.entry.sftp();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ readFile(path: string, maxBytes: number): Promise {
+ const p = sftpPath(path);
+ return this.sftpCall(async (sftp) => {
+ let st: Stats;
+ try {
+ st = await stat(sftp, p);
+ } catch (err) {
+ throw fileError(err, path, "read");
+ }
+ if (st.isDirectory()) throw new RemoteFileError(`${path} is a folder. List it with shell (ls -la).`);
+ if (st.size > maxBytes) throw new RemoteFileError(`${path} is too large to read (${st.size.toLocaleString("en-US")} bytes). Look at parts of it with shell (head, tail, grep, sed -n) or download it.`);
+ return new Promise((resolve, reject) => sftp.readFile(p, (err, data) => (err ? reject(fileError(err, path, "read")) : resolve(data))));
+ });
+ }
+
+ writeFile(path: string, data: string | Buffer): Promise {
+ const p = sftpPath(path);
+ return this.sftpCall(async (sftp) => {
+ await mkdirp(sftp, posix.dirname(p));
+ await new Promise((resolve, reject) => sftp.writeFile(p, data, (err) => (err ? reject(fileError(err, path, "write")) : resolve())));
+ });
+ }
+
+ /** Size of a remote file (null when it doesn't exist); throws for folders. */
+ fileSize(path: string): Promise {
+ const p = sftpPath(path);
+ return this.sftpCall(async (sftp) => {
+ try {
+ const st = await stat(sftp, p);
+ if (st.isDirectory()) throw new RemoteFileError(`${path} is a folder.`);
+ return st.size;
+ } catch (err) {
+ if (err instanceof RemoteFileError) throw err;
+ if ((err as { code?: number }).code === SFTP_NO_SUCH_FILE) return null;
+ throw fileError(err, path, "open");
+ }
+ });
+ }
+
+ isDirectory(path: string): Promise {
+ const p = sftpPath(path);
+ return this.sftpCall(async (sftp) => {
+ try {
+ return (await stat(sftp, p)).isDirectory();
+ } catch {
+ return false;
+ }
+ });
+ }
+
+ download(remote: string, local: string): Promise {
+ const p = sftpPath(remote);
+ return this.sftpCall(
+ (sftp) => new Promise((resolve, reject) => sftp.fastGet(p, local, (err) => (err ? reject(fileError(err, remote, "download")) : resolve()))),
+ );
+ }
+
+ upload(local: string, remote: string): Promise {
+ const p = sftpPath(remote);
+ return this.sftpCall(async (sftp) => {
+ await mkdirp(sftp, posix.dirname(p));
+ await new Promise((resolve, reject) => sftp.fastPut(local, p, (err) => (err ? reject(fileError(err, remote, "write")) : resolve())));
+ });
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Operating system */
+/* ------------------------------------------------------------------ */
+
+export const OS_PROBE =
+ 'uname -srm 2>/dev/null; if [ -r /etc/os-release ]; then . /etc/os-release; echo "$PRETTY_NAME"; elif command -v sw_vers >/dev/null 2>&1; then echo "$(sw_vers -productName) $(sw_vers -productVersion)"; fi';
+
+/** "Ubuntu 24.04.1 LTS Β· Linux 6.8.0-45-generic x86_64" from the output of OS_PROBE. */
+export function describeOs(stdout: string): string | null {
+ const [kernel, pretty] = stdout.split("\n").map((l) => l.trim());
+ const parts = [pretty, kernel].filter((p): p is string => !!p && p.length < 150);
+ return parts.length ? parts.join(" Β· ") : null;
+}
diff --git a/packages/core/src/ssh/keys.ts b/packages/core/src/ssh/keys.ts
new file mode 100644
index 00000000..37741bb2
--- /dev/null
+++ b/packages/core/src/ssh/keys.ts
@@ -0,0 +1,170 @@
+/**
+ * SSH keys: private keys (OpenSSH, PEM, PuTTY) as saved for a server, fingerprints as OpenSSH prints them, new Ed25519
+ * key pairs, and the private keys in ~/.ssh of the computer running Godmode.
+ */
+import { createHash } from "node:crypto";
+import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { utils, type ParsedKey } from "ssh2";
+import type { SshGeneratedKey, SshKeyInfo, SshLocalKey } from "@godmode/shared";
+
+const MAX_KEY_BYTES = 64 * 1024;
+const PRIVATE_KEY = /-----BEGIN (?:OPENSSH |RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----|^PuTTY-User-Key-File-\d+:/m;
+const PUBLIC_KEY_LINE = /^(?:ssh-[\w.@-]+|ecdsa-sha2-[\w.@-]+|sk-[\w.@-]+)\s+AAAA[\w+/=]+/;
+
+/** A private key that can't be used, in words for the human. */
+export class SshKeyError extends Error {
+ constructor(
+ message: string,
+ readonly code: "invalid" | "passphrase_required" | "bad_passphrase",
+ ) {
+ super(message);
+ }
+}
+
+/** OpenSSH's fingerprint of a public key blob: "SHA256:" and the unpadded base64 of its SHA-256. */
+export function fingerprintOf(blob: Buffer): string {
+ return `SHA256:${createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
+}
+
+/** The key type a public key blob starts with, e.g. "ssh-ed25519". */
+export function blobType(blob: Buffer): string {
+ if (blob.length < 4) return "unknown";
+ const len = blob.readUInt32BE(0);
+ return len > 0 && len < 64 && blob.length >= 4 + len ? blob.subarray(4, 4 + len).toString("latin1") : "unknown";
+}
+
+function infoOf(key: ParsedKey, encrypted: boolean): SshKeyInfo {
+ const blob = key.getPublicSSH();
+ return {
+ type: key.type,
+ fingerprint: fingerprintOf(blob),
+ publicKey: `${key.type} ${blob.toString("base64")}${key.comment ? ` ${key.comment}` : ""}`,
+ encrypted,
+ };
+}
+
+function firstKey(parsed: ParsedKey | ParsedKey[] | Error): ParsedKey | Error {
+ return Array.isArray(parsed) ? (parsed[0] ?? new Error("no key found")) : parsed;
+}
+
+/** Parse a private key (with its passphrase when it has one); throws SshKeyError with a message for the human. */
+export function parsePrivateKey(text: string, passphrase?: string | null): { key: ParsedKey; info: SshKeyInfo } {
+ const trimmed = text.trim();
+ if (!trimmed) throw new SshKeyError("The private key is empty.", "invalid");
+ if (Buffer.byteLength(trimmed) > MAX_KEY_BYTES) throw new SshKeyError("That is too long for a private key.", "invalid");
+ if (PUBLIC_KEY_LINE.test(trimmed)) {
+ throw new SshKeyError("That's a public key. Use the private key β the file without .pub, e.g. ~/.ssh/id_ed25519.", "invalid");
+ }
+ const plain = firstKey(utils.parseKey(trimmed) as ParsedKey | ParsedKey[] | Error);
+ if (!(plain instanceof Error)) {
+ if (!plain.isPrivateKey()) throw new SshKeyError("That's a public key. Use the private key β the file without .pub.", "invalid");
+ return { key: plain, info: infoOf(plain, false) };
+ }
+ if (!/passphrase|encrypted/i.test(plain.message)) {
+ throw new SshKeyError(`This isn't a private key Godmode can read (OpenSSH, PEM or PuTTY): ${plain.message}`, "invalid");
+ }
+ if (!passphrase) throw new SshKeyError("This key is protected with a passphrase β enter it as well.", "passphrase_required");
+ const unlocked = firstKey(utils.parseKey(trimmed, passphrase) as ParsedKey | ParsedKey[] | Error);
+ if (unlocked instanceof Error || !unlocked.isPrivateKey()) throw new SshKeyError("The passphrase doesn't unlock this key.", "bad_passphrase");
+ return { key: unlocked, info: infoOf(unlocked, true) };
+}
+
+/** A new Ed25519 key pair in OpenSSH format. */
+export function generateKeyPair(comment = "godmode"): SshGeneratedKey {
+ const pair = utils.generateKeyPairSync("ed25519", { comment });
+ const { info } = parsePrivateKey(pair.private);
+ return { privateKey: pair.private, publicKey: pair.public.trim(), type: info.type, fingerprint: info.fingerprint };
+}
+
+/** Public key blob stored in the clear inside an OpenSSH private key (also when the key itself is encrypted). */
+function openSshPublicBlob(text: string): Buffer | null {
+ const m = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/.exec(text);
+ if (!m?.[1]) return null;
+ try {
+ const data = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
+ const magic = "openssh-key-v1\0";
+ if (data.subarray(0, magic.length).toString("latin1") !== magic) return null;
+ let off = magic.length;
+ const str = () => {
+ const len = data.readUInt32BE(off);
+ off += 4;
+ const s = data.subarray(off, off + len);
+ off += len;
+ return s;
+ };
+ str(); // cipher
+ str(); // kdf
+ str(); // kdf options
+ const count = data.readUInt32BE(off);
+ off += 4;
+ return count > 0 ? str() : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Where Godmode looks for the human's own keys. */
+export function sshDir(): string {
+ return join(process.env.HOME || homedir(), ".ssh");
+}
+
+const NOT_KEYS = /^(?:known_hosts|authorized_keys|config|environment|allowed_signers)(?:[._-].*)?$|\.(?:pub|old|bak|sock|txt|md)$/i;
+
+/** Private keys in ~/.ssh on this computer (for importing into a server; nothing is copied until the human picks one). */
+export function listLocalKeys(): SshLocalKey[] {
+ const dir = sshDir();
+ if (!existsSync(dir)) return [];
+ const keys: SshLocalKey[] = [];
+ let entries: string[];
+ try {
+ entries = readdirSync(dir);
+ } catch {
+ return [];
+ }
+ for (const name of entries.sort()) {
+ if (name.startsWith(".") || NOT_KEYS.test(name)) continue;
+ const path = join(dir, name);
+ let text: string;
+ try {
+ const st = statSync(path);
+ if (!st.isFile() || st.size > MAX_KEY_BYTES) continue;
+ text = readFileSync(path, "utf8");
+ } catch {
+ continue;
+ }
+ if (!PRIVATE_KEY.test(text)) continue;
+ let type = "";
+ let fingerprint = "";
+ let comment = "";
+ let encrypted = false;
+ try {
+ const { info, key } = parsePrivateKey(text);
+ ({ type, fingerprint } = info);
+ comment = key.comment;
+ } catch (err) {
+ if (!(err instanceof SshKeyError) || err.code !== "passphrase_required") continue;
+ encrypted = true;
+ const pubLine = existsSync(`${path}.pub`) ? readFileSync(`${path}.pub`, "utf8").trim() : "";
+ const [pubType, pubData, ...rest] = pubLine.split(/\s+/);
+ const blob = pubData ? Buffer.from(pubData, "base64") : openSshPublicBlob(text);
+ if (blob?.length) {
+ type = pubType || blobType(blob);
+ fingerprint = fingerprintOf(blob);
+ }
+ comment = rest.join(" ");
+ }
+ keys.push({ path, name, type: type || "unknown", fingerprint, encrypted, comment });
+ }
+ return keys;
+}
+
+/** The contents of a key listed by `listLocalKeys` (anything else is refused). */
+export function readLocalKey(path: string): string {
+ const key = listLocalKeys().find((k) => k.path === path);
+ if (!key) throw new SshKeyError("That key isn't in ~/.ssh on this computer (anymore).", "invalid");
+ const real = realpathSync(path);
+ if (!existsSync(real)) throw new SshKeyError("That key isn't in ~/.ssh on this computer (anymore).", "invalid");
+ return readFileSync(real, "utf8");
+}
diff --git a/packages/core/src/ssh/service.ts b/packages/core/src/ssh/service.ts
new file mode 100644
index 00000000..75944f17
--- /dev/null
+++ b/packages/core/src/ssh/service.ts
@@ -0,0 +1,533 @@
+/**
+ * SSH servers (owner: ssh): records with their password, private key and passphrase sealed in the vault, connection
+ * tests, the human's "Run command", and the runs that may use them (their folders on this computer and an abort
+ * signal for their in-flight calls).
+ */
+import type {
+ SshAssignInput,
+ SshAuthMethod,
+ SshExecInput,
+ SshExecResult,
+ SshHostKey,
+ SshKeyInfo,
+ SshServer,
+ SshServerInput,
+ SshServerPatch,
+ SshTestInput,
+ SshTestResult,
+} from "@godmode/shared";
+import { all, get, insert, run, tx, update } from "../db";
+import { bus } from "../events/bus";
+import { logger } from "../log";
+import { getAgent, updateAgent } from "../agents/service";
+import { audit } from "../services/audit";
+import { getConversationSummary, updateConversation } from "../services/conversations";
+import { HttpError, badRequest, newId, notFound, now, parseJson } from "../util";
+import { open, openOptional, seal } from "../vault/vault";
+import { removeServerEverywhere, sshAssignments } from "./assignments";
+import { OS_PROBE, SshError, addressOf, connect, describeOs, dropConnection, execOn, withConnection, type Connection, type ConnectTarget } from "./client";
+import { SshKeyError, parsePrivateKey, readLocalKey } from "./keys";
+
+const log = logger("ssh");
+
+const HOST_KEY_FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
+const MAX_EXEC_OUTPUT = 200_000;
+
+interface SshServerRow {
+ id: string;
+ name: string;
+ host: string;
+ port: number;
+ username: string;
+ auth: SshAuthMethod;
+ description: string;
+ password_enc: string | null;
+ private_key_enc: string | null;
+ passphrase_enc: string | null;
+ key_info: string | null;
+ host_key_type: string | null;
+ host_key_fingerprint: string | null;
+ os: string | null;
+ last_connected_at: string | null;
+ last_error: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+const context = (field: "password" | "private_key" | "passphrase", id: string) => `ssh_servers.${field}:${id}`;
+
+function hostKeyOf(r: SshServerRow): SshHostKey | null {
+ return r.host_key_fingerprint ? { type: r.host_key_type ?? "unknown", fingerprint: r.host_key_fingerprint } : null;
+}
+
+function toServer(r: SshServerRow): SshServer {
+ return {
+ id: r.id,
+ name: r.name,
+ host: r.host,
+ port: r.port,
+ username: r.username,
+ auth: r.auth === "key" ? "key" : "password",
+ hasPassword: !!r.password_enc,
+ key: r.auth === "key" ? parseJson(r.key_info, null) : null,
+ description: r.description,
+ hostKey: hostKeyOf(r),
+ os: r.os,
+ lastConnectedAt: r.last_connected_at,
+ lastError: r.last_error,
+ assignments: sshAssignments(r.id),
+ createdAt: r.created_at,
+ updatedAt: r.updated_at,
+ };
+}
+
+function row(id: string): SshServerRow | null {
+ return get("SELECT * FROM ssh_servers WHERE id = ?", id);
+}
+
+function requireRow(id: string): SshServerRow {
+ const r = row(id);
+ if (!r) throw notFound("SSH server");
+ return r;
+}
+
+export function listServers(): SshServer[] {
+ return all("SELECT * FROM ssh_servers ORDER BY name COLLATE NOCASE, created_at").map(toServer);
+}
+
+export function getServer(id: string): SshServer {
+ return toServer(requireRow(id));
+}
+
+/* ------------------------------------------------------------------ */
+/* Validation */
+/* ------------------------------------------------------------------ */
+
+function normalizeName(v: string | undefined): string {
+ const name = (v ?? "").trim();
+ if (!name) throw badRequest("Give the server a name");
+ return name.slice(0, 100);
+}
+
+/** A host name or an IP address (IPv6 with or without brackets); no user, port or path. */
+function normalizeHost(v: string | undefined): string {
+ let host = (v ?? "").trim();
+ if (/^\[.*\]$/.test(host)) host = host.slice(1, -1);
+ if (!host || host.length > 253 || /[\s@/\\?#]/.test(host) || /[\u0000-\u001f]/.test(host)) {
+ throw badRequest("Enter the server's host name or IP address β without user@, a port or a path");
+ }
+ return host;
+}
+
+function normalizePort(v: number | undefined): number {
+ const port = v ?? 22;
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw badRequest("The port must be a number from 1 to 65535");
+ return port;
+}
+
+function normalizeUsername(v: string | undefined): string {
+ const user = (v ?? "").trim();
+ if (!user || user.length > 100 || /[\s\u0000-\u001f]/.test(user)) throw badRequest("Enter the user name to sign in with");
+ return user;
+}
+
+function normalizeHostKey(v: SshHostKey | null | undefined): SshHostKey | null | undefined {
+ if (v === undefined || v === null) return v;
+ if (!HOST_KEY_FINGERPRINT.test(v.fingerprint)) throw badRequest("That host key fingerprint isn't valid (expected SHA256:β¦)");
+ return { type: (v.type || "unknown").slice(0, 60), fingerprint: v.fingerprint };
+}
+
+function keyProblem(err: unknown): never {
+ if (err instanceof SshKeyError) throw new HttpError(400, err.message, err.code === "invalid" ? "bad_request" : err.code);
+ throw err;
+}
+
+interface Secrets {
+ password: string | null;
+ privateKey: string | null;
+ passphrase: string | null;
+ keyInfo: SshKeyInfo | null;
+}
+
+/**
+ * The secrets a server ends up with after `input` (undefined fields keep what `current` has). Throws with a message
+ * for the human when something required is missing or the key can't be used.
+ */
+function resolveSecrets(auth: SshAuthMethod, input: SshServerPatch, current: Secrets | null): Secrets {
+ const password = input.password === undefined ? (current?.password ?? null) : input.password || null;
+ if (auth === "password") {
+ if (!password) throw badRequest("Enter the password");
+ return { password, privateKey: null, passphrase: null, keyInfo: null };
+ }
+ let text: string | null = null;
+ try {
+ if (input.privateKeyPath) text = readLocalKey(input.privateKeyPath);
+ else if (input.privateKey?.trim()) text = input.privateKey;
+ } catch (err) {
+ keyProblem(err);
+ }
+ const passphrase = input.passphrase === undefined ? (current?.passphrase ?? null) : input.passphrase || null;
+ const keyText = text ?? current?.privateKey ?? null;
+ if (!keyText) throw badRequest("Add the private key: paste it, load a file or generate a new one");
+ if (!text && input.passphrase === undefined && current?.keyInfo) {
+ return { password, privateKey: keyText, passphrase, keyInfo: current.keyInfo };
+ }
+ let info: SshKeyInfo;
+ try {
+ info = parsePrivateKey(keyText, passphrase).info;
+ } catch (err) {
+ keyProblem(err);
+ }
+ return { password, privateKey: `${keyText.trim()}\n`, passphrase: info.encrypted ? passphrase : null, keyInfo: info };
+}
+
+function secretsOf(r: SshServerRow): Secrets {
+ return {
+ password: openOptional(r.password_enc, context("password", r.id)),
+ privateKey: openOptional(r.private_key_enc, context("private_key", r.id)),
+ passphrase: openOptional(r.passphrase_enc, context("passphrase", r.id)),
+ keyInfo: parseJson(r.key_info, null),
+ };
+}
+
+function sealed(id: string, s: Secrets) {
+ return {
+ password_enc: s.password ? seal(s.password, context("password", id)) : null,
+ private_key_enc: s.privateKey ? seal(s.privateKey, context("private_key", id)) : null,
+ passphrase_enc: s.passphrase ? seal(s.passphrase, context("passphrase", id)) : null,
+ key_info: s.keyInfo ? JSON.stringify(s.keyInfo) : null,
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* CRUD */
+/* ------------------------------------------------------------------ */
+
+export function createServer(input: SshServerInput): SshServer {
+ const auth: SshAuthMethod = input.auth === "key" ? "key" : "password";
+ const fields = {
+ name: normalizeName(input.name),
+ host: normalizeHost(input.host),
+ port: normalizePort(input.port),
+ username: normalizeUsername(input.username),
+ };
+ const hostKey = normalizeHostKey(input.hostKey) ?? null;
+ const secrets = resolveSecrets(auth, input, null);
+ const id = newId("ssh");
+ const ts = now();
+ insert("ssh_servers", {
+ id,
+ ...fields,
+ auth,
+ description: (input.description ?? "").trim().slice(0, 4000),
+ ...sealed(id, secrets),
+ host_key_type: hostKey?.type ?? null,
+ host_key_fingerprint: hostKey?.fingerprint ?? null,
+ created_at: ts,
+ updated_at: ts,
+ });
+ log.info("SSH server added", { server: id, host: addressOf(fields), auth });
+ audit("user", "ssh.create", id, { name: fields.name, host: addressOf(fields), username: fields.username, auth });
+ bus.changed("ssh-servers");
+ return getServer(id);
+}
+
+export function updateServer(id: string, patch: SshServerPatch): SshServer {
+ const current = requireRow(id);
+ const auth: SshAuthMethod = patch.auth === undefined ? current.auth : patch.auth === "key" ? "key" : "password";
+ const host = patch.host === undefined ? current.host : normalizeHost(patch.host);
+ const port = patch.port === undefined ? current.port : normalizePort(patch.port);
+ const touchesSecrets =
+ patch.auth !== undefined || patch.password !== undefined || patch.privateKey !== undefined || patch.privateKeyPath !== undefined || patch.passphrase !== undefined;
+ const secrets = touchesSecrets ? resolveSecrets(auth, patch, secretsOf(current)) : null;
+ // Another machine answers at a new address: the old host key means nothing there.
+ const moved = host !== current.host || port !== current.port;
+ const hostKey = patch.hostKey !== undefined ? normalizeHostKey(patch.hostKey) : moved ? null : undefined;
+ update("ssh_servers", id, {
+ name: patch.name === undefined ? undefined : normalizeName(patch.name),
+ host,
+ port,
+ username: patch.username === undefined ? undefined : normalizeUsername(patch.username),
+ auth,
+ description: patch.description === undefined ? undefined : patch.description.trim().slice(0, 4000),
+ ...(secrets ? sealed(id, secrets) : {}),
+ ...(hostKey === undefined ? {} : { host_key_type: hostKey?.type ?? null, host_key_fingerprint: hostKey?.fingerprint ?? null }),
+ ...(moved || secrets ? { last_error: null } : {}),
+ ...(moved ? { os: null } : {}),
+ updated_at: now(),
+ });
+ dropConnection(id);
+ if (moved) osProbed.delete(id);
+ audit("user", "ssh.update", id, {
+ name: patch.name ?? current.name,
+ ...(moved ? { host: addressOf({ host, port }) } : {}),
+ ...(secrets ? { secrets: true } : {}),
+ ...(hostKey !== undefined ? { hostKey: hostKey?.fingerprint ?? null } : {}),
+ });
+ bus.changed("ssh-servers");
+ return getServer(id);
+}
+
+export function deleteServer(id: string): void {
+ const current = requireRow(id);
+ const removed = tx(() => {
+ run("DELETE FROM ssh_servers WHERE id = ?", id);
+ return removeServerEverywhere(id);
+ });
+ dropConnection(id);
+ log.info("SSH server deleted", { server: id });
+ audit("user", "ssh.delete", id, { name: current.name });
+ bus.changed("ssh-servers");
+ if (removed.agents.length) bus.changed("agents");
+ for (const conversationId of removed.conversations) {
+ try {
+ bus.emit({ type: "conversation.updated", conversation: getConversationSummary(conversationId) });
+ } catch {
+ /* deleted meanwhile */
+ }
+ }
+}
+
+/** Give an agent or a chat the server, or take it away (human-only). */
+export async function assignServer(id: string, input: SshAssignInput): Promise {
+ requireRow(id);
+ const toggle = (ids: string[]) => (input.assigned ? [...new Set([...ids, id])] : ids.filter((x) => x !== id));
+ if (input.kind === "agent") {
+ const agent = getAgent(input.id);
+ await updateAgent(agent.id, { sshServerIds: toggle(agent.sshServerIds) });
+ } else {
+ const conversation = getConversationSummary(input.id);
+ updateConversation(conversation.id, { sshServerIds: toggle(conversation.sshServerIds) });
+ }
+ audit("user", input.assigned ? "ssh.assign" : "ssh.unassign", id, { kind: input.kind, target: input.id });
+ return getServer(id);
+}
+
+/* ------------------------------------------------------------------ */
+/* Connections */
+/* ------------------------------------------------------------------ */
+
+function targetOf(r: SshServerRow, secrets = secretsOf(r)): ConnectTarget {
+ return {
+ host: r.host,
+ port: r.port,
+ username: r.username,
+ auth: r.auth,
+ password: secrets.password,
+ privateKey: secrets.privateKey,
+ passphrase: secrets.passphrase,
+ hostKey: hostKeyOf(r),
+ };
+}
+
+/**
+ * Remember a successful connection: pin the host key the first time, clear the last error. A connection made before the
+ * server moved to another address says nothing about the new one.
+ */
+function recordConnected(id: string, to: { host: string; port: number }, hostKey: SshHostKey, os?: string | null): void {
+ const r = row(id);
+ if (!r || r.host !== to.host || r.port !== to.port) return;
+ update("ssh_servers", id, {
+ ...(r.host_key_fingerprint ? {} : { host_key_type: hostKey.type, host_key_fingerprint: hostKey.fingerprint }),
+ ...(os ? { os } : {}),
+ last_connected_at: now(),
+ last_error: null,
+ });
+ bus.changed("ssh-servers");
+}
+
+function recordFailure(id: string, message: string): void {
+ if (!row(id)) return;
+ update("ssh_servers", id, { last_error: message });
+ bus.changed("ssh-servers");
+}
+
+const osProbed = new Set();
+
+async function probeOs(conn: Connection): Promise {
+ try {
+ const res = await conn.exec(OS_PROBE, { timeoutMs: 10_000 });
+ return describeOs(res.stdout);
+ } catch {
+ return null;
+ }
+}
+
+/** A signed-in connection to the server (pooled) for the duration of `fn`. */
+export async function useServer(id: string, fn: (conn: Connection) => Promise, signal?: AbortSignal): Promise {
+ const r = requireRow(id);
+ let connected = false;
+ return withConnection(
+ id,
+ {
+ version: r.updated_at,
+ target: () => targetOf(requireRow(id)),
+ onConnect: (session, target) => {
+ connected = true;
+ recordConnected(id, target, session.hostKey);
+ },
+ onError: (err) => recordFailure(id, err.message),
+ },
+ async (conn) => {
+ if (connected && !r.os && !osProbed.has(id)) {
+ osProbed.add(id);
+ void useServer(id, probeOs).then(
+ (os) => {
+ if (!os || !row(id)) return;
+ update("ssh_servers", id, { os });
+ bus.changed("ssh-servers");
+ },
+ () => undefined,
+ );
+ }
+ return fn(conn);
+ },
+ signal,
+ );
+}
+
+async function probe(target: ConnectTarget): Promise {
+ let session: Awaited> | null = null;
+ try {
+ session = await connect(target);
+ const res = await execOn(session.client, OS_PROBE, { timeoutMs: 10_000 }).catch(() => null);
+ return { ok: true, error: null, stage: null, hostKey: session.hostKey, hostKeyChanged: false, os: res ? describeOs(res.stdout) : null, latencyMs: session.latencyMs };
+ } catch (err) {
+ if (!(err instanceof SshError)) throw err;
+ return { ok: false, error: err.message, stage: err.stage, hostKey: err.hostKey, hostKeyChanged: err.hostKeyChanged, os: null, latencyMs: null };
+ } finally {
+ session?.client.end();
+ }
+}
+
+/** Sign in to a saved server; the first successful test pins its host key. */
+export async function testServer(id: string): Promise {
+ const r = requireRow(id);
+ const result = await probe(targetOf(r));
+ if (result.ok && result.hostKey) recordConnected(id, r, result.hostKey, result.os);
+ else if (result.error) recordFailure(id, result.error);
+ return result;
+}
+
+/** Try settings before they are saved (secrets the input leaves out come from the saved server `id`). Records nothing. */
+export async function tryServer(input: SshTestInput): Promise {
+ const fail = (error: string): SshTestResult => ({ ok: false, error, stage: "config", hostKey: null, hostKeyChanged: false, os: null, latencyMs: null });
+ const current = input.id ? requireRow(input.id) : null;
+ let target: ConnectTarget;
+ try {
+ const auth: SshAuthMethod = input.auth === "key" ? "key" : input.auth === "password" ? "password" : (current?.auth ?? "password");
+ const host = normalizeHost(input.host ?? current?.host);
+ const port = normalizePort(input.port ?? current?.port);
+ const secrets = resolveSecrets(auth, input, current ? secretsOf(current) : null);
+ const moved = !!current && (host !== current.host || port !== current.port);
+ const pinned = input.hostKey !== undefined ? normalizeHostKey(input.hostKey) : current && !moved ? hostKeyOf(current) : null;
+ target = {
+ host,
+ port,
+ username: normalizeUsername(input.username ?? current?.username),
+ auth,
+ password: secrets.password,
+ privateKey: secrets.privateKey,
+ passphrase: secrets.passphrase,
+ hostKey: pinned ?? null,
+ };
+ } catch (err) {
+ if (err instanceof HttpError && err.status === 400) return fail(err.message);
+ throw err;
+ }
+ return probe(target);
+}
+
+function clipOutput(s: string): string {
+ return s.length > MAX_EXEC_OUTPUT ? `${s.slice(0, MAX_EXEC_OUTPUT / 2)}\n\nβ¦ [output shortened] β¦\n\n${s.slice(-MAX_EXEC_OUTPUT / 2)}` : s;
+}
+
+/** The human's "Run command" on a server. */
+export async function execForHuman(id: string, input: SshExecInput): Promise {
+ const timeoutMs = Math.min(Math.max(input.timeoutSeconds ?? 120, 1), 600) * 1000;
+ try {
+ const res = await useServer(id, (conn) => conn.exec(input.command, { timeoutMs }));
+ const stderr = res.lost ? `${res.stderr}\nThe connection was lost while the command ran.` : res.stderr;
+ return { exitCode: res.exitCode, stdout: clipOutput(res.stdout), stderr: clipOutput(stderr), timedOut: res.timedOut, durationMs: res.durationMs };
+ } catch (err) {
+ if (err instanceof SshError) throw new HttpError(502, err.message, `ssh_${err.stage.replace("-", "_")}`);
+ throw err;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Runs */
+/* ------------------------------------------------------------------ */
+
+/** A server as the run's system prompt describes it. */
+export interface PromptSshServer {
+ id: string;
+ name: string;
+ username: string;
+ /** user@host, with :port when it isn't 22 */
+ address: string;
+ os: string | null;
+ description: string;
+ /** A password is saved, so `sudo: true` can answer sudo's prompt. */
+ sudoPassword: boolean;
+}
+
+export function promptServers(ids: string[]): PromptSshServer[] {
+ return ids.flatMap((id) => {
+ const r = row(id);
+ return r
+ ? [
+ {
+ id: r.id,
+ name: r.name,
+ username: r.username,
+ address: `${r.username}@${addressOf(r)}`,
+ os: r.os,
+ description: r.description,
+ sudoPassword: !!r.password_enc,
+ },
+ ]
+ : [];
+ });
+}
+
+/** The secrets of a server that must never reach the model (masked in tool results): also every line of the key. */
+export function serverSecrets(id: string): string[] {
+ const r = row(id);
+ if (!r) return [];
+ const s = secretsOf(r);
+ const keyLines = (s.privateKey ?? "").split(/\r?\n/).filter((l) => l.length >= 20 && !l.startsWith("-----"));
+ return [s.password, s.passphrase, ...keyLines].filter((v): v is string => !!v && v.length >= 4);
+}
+
+/** The password sudo asks for, when one is saved. */
+export function sudoPassword(id: string): string | null {
+ const r = row(id);
+ return r?.password_enc ? open(r.password_enc, context("password", id)) : null;
+}
+
+interface RunSsh {
+ /** Folders on this computer the run works with (uploads come from and downloads go to these). */
+ folders: string[];
+ abort: AbortController;
+}
+
+const runs = new Map();
+
+/** The run may use SSH servers: its tools work until `detachSsh`. */
+export function attachSsh(runId: string, folders: string[]): void {
+ runs.set(runId, { folders, abort: new AbortController() });
+}
+
+/** The run ended: its in-flight SSH calls are cancelled. */
+export function detachSsh(runId: string): void {
+ const entry = runs.get(runId);
+ if (!entry) return;
+ runs.delete(runId);
+ entry.abort.abort();
+}
+
+export function sshRun(runId: string): { folders: string[]; signal: AbortSignal } | null {
+ const entry = runs.get(runId);
+ return entry ? { folders: entry.folders, signal: entry.abort.signal } : null;
+}
diff --git a/packages/core/src/ssh/tools.ts b/packages/core/src/ssh/tools.ts
new file mode 100644
index 00000000..9d46b03e
--- /dev/null
+++ b/packages/core/src/ssh/tools.ts
@@ -0,0 +1,441 @@
+/**
+ * The `ssh` MCP server (POST /mcp/ssh, per-run bearer token): shell, file and transfer tools on the SSH servers a run
+ * may use β its chat's and its agent's, re-read on every call so a server taken away stops working at once. Godmode
+ * signs in with the saved password or key and answers sudo's prompt itself; the secrets are masked in every result.
+ */
+import { randomBytes } from "node:crypto";
+import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
+import { z } from "zod";
+import { getAgent } from "../agents/service";
+import { logger } from "../log";
+import { audit } from "../services/audit";
+import type { RunContext } from "../types";
+import { HttpError } from "../util";
+import { runSshServerIds } from "./assignments";
+import { RemoteFileError, SshError, shellPath, shellQuote, type Connection } from "./client";
+import { promptServers, serverSecrets, sshRun, sudoPassword, useServer, type PromptSshServer } from "./service";
+
+const log = logger("ssh");
+
+export interface SshToolResult {
+ content: { type: "text"; text: string }[];
+ isError?: boolean;
+}
+
+const MAX_OUTPUT = 30_000;
+const MAX_READ_BYTES = 2_000_000;
+const DEFAULT_READ_LINES = 2000;
+
+export const SSH_INSTRUCTIONS =
+ "Tools for the SSH servers this task may use: run shell commands, read, write and edit files, and copy files between this computer and a server. " +
+ "Godmode signs in (and answers sudo) for you β you never see passwords or keys. Pass `server` (its name or id) when more than one server is available.";
+
+function text(t: string, isError = false): SshToolResult {
+ return { content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) };
+}
+
+function clip(s: string, max = MAX_OUTPUT): string {
+ if (s.length <= max) return s;
+ const head = Math.floor(max / 3);
+ return `${s.slice(0, head)}\n\nβ¦ [${(s.length - max).toLocaleString("en-US")} characters omitted] β¦\n\n${s.slice(-(max - head))}`;
+}
+
+/** Whatever a command prints, the server's saved secrets are masked before it reaches the model. */
+function mask(s: string, secrets: string[]): string {
+ let out = s;
+ for (const secret of secrets) if (out.includes(secret)) out = out.split(secret).join("β’β’β’β’β’β’β’β’");
+ return out;
+}
+
+interface ToolEnv {
+ server: PromptSshServer;
+ conn: Connection;
+ runId: string;
+ agentId: string;
+ folders: string[];
+}
+
+interface SshTool {
+ name: string;
+ description: string;
+ schema: z.ZodType;
+ run: (args: never, env: ToolEnv) => Promise;
+}
+
+function defineTool(def: { name: string; description: string; schema: S; run: (args: z.infer, env: ToolEnv) => Promise }): SshTool {
+ return def as unknown as SshTool;
+}
+
+const serverArg = z.string().max(200).optional().describe("Server name or id (optional when only one server is available)");
+
+/* ------------------------------------------------------------------ */
+/* Local paths (uploads and downloads) */
+/* ------------------------------------------------------------------ */
+
+function inside(path: string, folder: string): boolean {
+ const rel = relative(folder, path);
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
+}
+
+/** The real path, also for one that doesn't exist yet (its nearest existing folder resolved). */
+function realPath(path: string): string {
+ try {
+ return realpathSync(path);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
+ // A link that points nowhere would be followed when the file is created.
+ let link = false;
+ try {
+ link = lstatSync(path).isSymbolicLink();
+ } catch {
+ /* doesn't exist */
+ }
+ if (link) throw new RemoteFileError(`${path} is a link to a file that doesn't exist; use another path.`);
+ const parent = dirname(path);
+ return parent === path ? path : join(realPath(parent), basename(path));
+ }
+}
+
+/** Settings and hooks there would run programs on this computer (Claude Code's own file tools can't edit them either). */
+const PROTECTED_DIRS = new Set([".git", ".claude"]);
+
+/**
+ * A path on this computer inside the run's folders (relative ones are relative to the first, the run's working
+ * directory). Symlinks can't lead out of them; files are never written into a .git or .claude folder.
+ */
+function localPath(input: string, folders: string[], mode: "read" | "write"): string {
+ if (!folders.length) throw new RemoteFileError("This run has no folders on this computer to copy files from or to.");
+ const real = realPath(resolve(folders[0]!, input.replace(/^~(?=$|[\\/])/, homedir())));
+ const folder = folders.map(realPath).find((f) => inside(real, f));
+ if (!folder) {
+ throw new RemoteFileError(`${input} is outside the folders of this run. Use a path in ${folders.map((f) => `\`${f}\``).join(", ")}.`);
+ }
+ if (mode === "write" && relative(folder, real).split(/[\\/]/).some((part) => PROTECTED_DIRS.has(part))) {
+ throw new RemoteFileError(`Godmode doesn't write files into .git or .claude folders on this computer. Use another path.`);
+ }
+ if (mode === "read" && !existsSync(real)) throw new RemoteFileError(`No such file on this computer: ${input}`);
+ return real;
+}
+
+/* ------------------------------------------------------------------ */
+/* Tools */
+/* ------------------------------------------------------------------ */
+
+/** A failed `cd` stops there, whatever the command is made of. */
+function commandScript(command: string, cwd: string | undefined): string {
+ return cwd ? `cd -- ${shellPath(cwd)} || exit\n${command}` : command;
+}
+
+async function runShell(
+ env: ToolEnv,
+ args: { command: string; cwd?: string; stdin?: string; sudo?: boolean; timeout_seconds?: number },
+): Promise {
+ const timeoutMs = (args.timeout_seconds ?? 120) * 1000;
+ const script = commandScript(args.command, args.cwd);
+ let command = script;
+ let prompt: { marker: string; answer: string; waitMs: number } | undefined;
+ if (args.sudo && env.server.username !== "root") {
+ // The same shell as without sudo: the user's login shell.
+ const asRoot = `-- "\${SHELL:-/bin/sh}" -c ${shellQuote(script)}`;
+ const probe = await env.conn.exec("sudo -n true", { timeoutMs: 15_000 });
+ if (probe.exitCode === 127) return text(`sudo isn't installed on ${env.server.name}.`, true);
+ if (probe.exitCode === 0) command = `sudo -n ${asRoot}`;
+ else {
+ const password = sudoPassword(env.server.id);
+ if (!password) {
+ return text(
+ `sudo on ${env.server.name} asks for a password, and none is saved for this server. Ask the human to add it in SSH servers β ${env.server.name} β Edit ("Password for sudo").`,
+ true,
+ );
+ }
+ // The password only goes out when sudo prints this prompt (-k: it always asks when it needs one).
+ const marker = `godmode-sudo-${randomBytes(8).toString("hex")}:`;
+ command = `sudo -S -k -p ${shellQuote(marker)} ${asRoot}`;
+ prompt = { marker, answer: password, waitMs: 10_000 };
+ audit(`agent:${env.agentId}`, "ssh.sudo", env.server.id, { runId: env.runId });
+ }
+ }
+ const res = await env.conn.exec(command, { timeoutMs, stdin: args.stdin ?? "", prompt });
+ const stdout = res.stdout;
+ let stderr = res.stderr;
+ if (res.prompts > 1) stderr += `\nsudo rejected the saved password. Ask the human to check the password saved for ${env.server.name}.`;
+ const status = res.timedOut
+ ? `Timed out after ${args.timeout_seconds ?? 120} s. Godmode closed the session; a command that ignores that may still be running on the server (check with ps).`
+ : res.cancelled
+ ? "Cancelled."
+ : res.lost
+ ? `The connection to ${env.server.name} was lost while the command ran.`
+ : res.exitSignal
+ ? `Killed by signal ${res.exitSignal}`
+ : `Exit code: ${res.exitCode}`;
+ const parts = [status];
+ if (stdout) parts.push(clip(stdout.replace(/\s+$/, "")));
+ if (stderr.trim()) parts.push(`[stderr]\n${clip(stderr.replace(/\s+$/, ""), MAX_OUTPUT / 2)}`);
+ if (!stdout && !stderr.trim()) parts.push("(no output)");
+ return text(parts.join("\n"), res.timedOut || res.cancelled || res.lost || res.exitCode !== 0);
+}
+
+/** A file's text via SFTP, or `cat` when the server has no SFTP. */
+async function readText(env: ToolEnv, path: string): Promise {
+ if (await env.conn.hasSftp()) return (await env.conn.readFile(path, MAX_READ_BYTES)).toString("utf8");
+ const script = `f=${shellPath(path)}; if [ -d "$f" ]; then echo "$f is a folder" >&2; exit 21; fi; [ -f "$f" ] || { echo "No such file: $f" >&2; exit 2; }; s=$(wc -c < "$f" | tr -d ' '); if [ "$s" -gt ${MAX_READ_BYTES} ]; then echo "$f is too large to read ($s bytes)" >&2; exit 27; fi; cat -- "$f"`;
+ const res = await env.conn.exec(script, { timeoutMs: 60_000 });
+ if (res.exitCode !== 0) throw new RemoteFileError(res.stderr.trim() || `Couldn't read ${path} (exit ${res.exitCode})`);
+ return res.stdout;
+}
+
+async function writeText(env: ToolEnv, path: string, content: string): Promise {
+ if (await env.conn.hasSftp()) return env.conn.writeFile(path, content);
+ const res = await env.conn.exec(`f=${shellPath(path)}; mkdir -p "$(dirname "$f")" && cat > "$f"`, { timeoutMs: 60_000, stdin: content });
+ if (res.exitCode !== 0) throw new RemoteFileError(res.stderr.trim() || `Couldn't write ${path} (exit ${res.exitCode})`);
+}
+
+const NO_SFTP = "This server doesn't offer SFTP, so files can't be copied. Move small text files with read_file / write_file instead.";
+
+function formatBytes(n: number): string {
+ if (n < 1024) return `${n} bytes`;
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
+}
+
+/** Answered without a connection. */
+const LIST_TOOL = {
+ name: "list_servers",
+ description: "The SSH servers this task may use: name, id, address, operating system, what they are for, and whether sudo can be answered.",
+ schema: z.object({}),
+};
+
+const TOOLS: SshTool[] = [
+ defineTool({
+ name: "shell",
+ description:
+ "Run a shell command on an SSH server (in the user's login shell, like `ssh host 'command'`). Returns the exit code, stdout and stderr. " +
+ "Each call is a new session: pass cwd instead of relying on an earlier cd. Commands must not wait for input β use non-interactive flags (-y, --no-pager, DEBIAN_FRONTEND=noninteractive). " +
+ "Start long-running processes in the background with nohup and redirect their output to a file. " +
+ "sudo: true runs the command as root β Godmode answers sudo's password prompt, so never put a password into the command. stdin is passed to the command's standard input (e.g. file content for `tee`).",
+ schema: z.object({
+ server: serverArg,
+ command: z.string().min(1).max(100_000).describe("Shell command(s) to run"),
+ cwd: z.string().max(4096).optional().describe("Directory to run in (absolute, ~/β¦ or relative to the home folder)"),
+ stdin: z.string().max(5_000_000).optional().describe("Text for the command's standard input"),
+ sudo: z.boolean().optional().describe("Run as root with sudo (Godmode enters the password)"),
+ timeout_seconds: z.number().int().min(1).max(3600).optional().describe("Stop the command after this many seconds (default 120, max 3600)"),
+ }),
+ run: (args, env) => runShell(env, args),
+ }),
+ defineTool({
+ name: "read_file",
+ description: "Read a text file on an SSH server. Returns numbered lines (like cat -n). Use offset/limit for long files. Files only root can read: use shell with sudo: true.",
+ schema: z.object({
+ server: serverArg,
+ path: z.string().min(1).max(4096).describe("File path on the server (absolute, ~/β¦ or relative to the home folder)"),
+ offset: z.number().int().min(1).optional().describe("First line to return (1-based)"),
+ limit: z.number().int().min(1).max(20_000).optional().describe(`Number of lines (default ${DEFAULT_READ_LINES})`),
+ }),
+ run: async (args, env) => {
+ const content = await readText(env, args.path);
+ if (content.includes("\u0000")) return text(`${args.path} is a binary file. Inspect it with shell (file, xxd, β¦) or download it.`, true);
+ if (!content) return text(`${args.path} is empty.`);
+ const lines = content.replace(/\n$/, "").split("\n");
+ const start = (args.offset ?? 1) - 1;
+ const slice = lines.slice(start, start + (args.limit ?? DEFAULT_READ_LINES));
+ const width = String(start + slice.length).length;
+ const body = slice.map((l, i) => `${String(start + i + 1).padStart(width, " ")}\t${l.length > 2000 ? `${l.slice(0, 2000)}β¦` : l}`).join("\n");
+ const more = start + slice.length < lines.length ? `\n⦠${lines.length - start - slice.length} more lines (use offset ${start + slice.length + 1})` : "";
+ return text(clip(body + more, 200_000));
+ },
+ }),
+ defineTool({
+ name: "write_file",
+ description: "Create or overwrite a file on an SSH server (parent folders are created). For files owned by root, use shell with sudo: true and `tee ` with the content as stdin.",
+ schema: z.object({
+ server: serverArg,
+ path: z.string().min(1).max(4096).describe("File path on the server"),
+ content: z.string().max(5_000_000).describe("The complete new file content"),
+ }),
+ run: async (args, env) => {
+ await writeText(env, args.path, args.content);
+ return text(`Wrote ${Buffer.byteLength(args.content, "utf8").toLocaleString("en-US")} bytes to ${args.path} on ${env.server.name}.`);
+ },
+ }),
+ defineTool({
+ name: "edit_file",
+ description: "Replace text in a file on an SSH server. old_string must match the file exactly (including whitespace) and be unique unless replace_all is true. Read the file first.",
+ schema: z.object({
+ server: serverArg,
+ path: z.string().min(1).max(4096),
+ old_string: z.string().min(1).max(1_000_000),
+ new_string: z.string().max(1_000_000),
+ replace_all: z.boolean().optional(),
+ }),
+ run: async (args, env) => {
+ if (args.old_string === args.new_string) return text("old_string and new_string are the same β nothing to change.", true);
+ const content = await readText(env, args.path);
+ if (content.includes("\u0000") || content.includes("οΏ½")) return text(`${args.path} isn't UTF-8 text; edit it with shell tools (sed, perl, β¦) instead.`, true);
+ const count = content.split(args.old_string).length - 1;
+ if (count === 0) return text(`old_string was not found in ${args.path}. Read the file and copy the text exactly.`, true);
+ if (count > 1 && !args.replace_all) return text(`old_string occurs ${count} times in ${args.path}. Add surrounding context to make it unique, or set replace_all.`, true);
+ const next = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, () => args.new_string);
+ await writeText(env, args.path, next);
+ return text(`Edited ${args.path} on ${env.server.name} (${args.replace_all ? `${count} replacement${count === 1 ? "" : "s"}` : "1 replacement"}).`);
+ },
+ }),
+ defineTool({
+ name: "upload",
+ description:
+ "Copy a file from this computer to an SSH server (any size, binary too). local_path must be in your working directory, your repository or the other folders of this run; " +
+ "remote_path defaults to the file's name in the home folder. Parent folders are created; an existing file is replaced.",
+ schema: z.object({
+ server: serverArg,
+ local_path: z.string().min(1).max(4096).describe("File on this computer (relative to your working directory, or absolute)"),
+ remote_path: z.string().max(4096).optional().describe("Where to put it on the server (a folder that exists keeps the file's name)"),
+ }),
+ run: async (args, env) => {
+ const local = localPath(args.local_path, env.folders, "read");
+ if (!statSync(local).isFile()) return text(`${args.local_path} isn't a file. Upload files one at a time (pack a folder with tar first).`, true);
+ if (!(await env.conn.hasSftp())) return text(NO_SFTP, true);
+ let remote = args.remote_path?.trim() || basename(local);
+ if (remote.endsWith("/") || (await env.conn.isDirectory(remote))) remote = posix.join(remote, basename(local));
+ await env.conn.upload(local, remote);
+ return text(`Uploaded ${basename(local)} (${formatBytes(statSync(local).size)}) to ${remote} on ${env.server.name}.`);
+ },
+ }),
+ defineTool({
+ name: "download",
+ description:
+ "Copy a file from an SSH server to this computer (any size, binary too). local_path defaults to workspace/downloads/ in your repository; " +
+ "it must be in your working directory, your repository or the other folders of this run. An existing file is replaced.",
+ schema: z.object({
+ server: serverArg,
+ remote_path: z.string().min(1).max(4096).describe("File on the server"),
+ local_path: z.string().max(4096).optional().describe("Where to save it on this computer (relative to your working directory, or absolute)"),
+ }),
+ run: async (args, env) => {
+ if (!(await env.conn.hasSftp())) return text(NO_SFTP, true);
+ const size = await env.conn.fileSize(args.remote_path);
+ if (size === null) return text(`No such file on ${env.server.name}: ${args.remote_path}`, true);
+ const name = posix.basename(args.remote_path.replace(/\/+$/, "")) || "download";
+ const target = args.local_path?.trim() || join(getAgent(env.agentId).repoPath, "workspace", "downloads", name);
+ let local = localPath(target, env.folders, "write");
+ if (existsSync(local) && statSync(local).isDirectory()) local = localPath(join(local, name), env.folders, "write");
+ mkdirSync(dirname(local), { recursive: true });
+ // Into a new file next to the target, then renamed over it: nothing is written through a link.
+ const partial = join(dirname(local), `.${basename(local)}.${randomBytes(4).toString("hex")}.part`);
+ try {
+ await env.conn.download(args.remote_path, partial);
+ renameSync(partial, local);
+ } finally {
+ rmSync(partial, { force: true });
+ }
+ return text(`Downloaded ${args.remote_path} (${formatBytes(size)}) from ${env.server.name} to ${local}.`);
+ },
+ }),
+];
+
+/* ------------------------------------------------------------------ */
+/* Server */
+/* ------------------------------------------------------------------ */
+
+const schemaCache = new Map>();
+
+function jsonSchema(name: string, schema: z.ZodType): Record {
+ let s = schemaCache.get(name);
+ if (!s) {
+ s = z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }) as Record;
+ delete s.$schema;
+ schemaCache.set(name, s);
+ }
+ return s;
+}
+
+function serversOf(ctx: RunContext): PromptSshServer[] {
+ return promptServers(runSshServerIds(ctx.conversationId, ctx.agentId));
+}
+
+export function listSshTools(ctx: RunContext): { name: string; description: string; inputSchema: Record }[] {
+ if (!sshRun(ctx.runId)) return [];
+ return [LIST_TOOL, ...TOOLS].map((t) => ({ name: t.name, description: t.description, inputSchema: jsonSchema(t.name, t.schema) }));
+}
+
+export class UnknownSshToolError extends Error {}
+
+function pickServer(servers: PromptSshServer[], wanted: string | undefined): PromptSshServer | string {
+ const names = servers.map((s) => `"${s.name}"`).join(", ");
+ if (!servers.length) return "No SSH servers are available to this task anymore β the human removed them.";
+ const key = wanted?.trim();
+ if (!key) return servers.length === 1 ? servers[0]! : `Several servers are available (${names}): pass server.`;
+ const byId = servers.find((s) => s.id === key);
+ if (byId) return byId;
+ const k = key.toLowerCase();
+ // The exact name first, then a unique shorter form: "web-1" for "web-1 (staging)", the host, or user@host.
+ for (const matches of [
+ (s: PromptSshServer) => s.name.toLowerCase() === k,
+ (s: PromptSshServer) => s.name.toLowerCase().startsWith(k) || s.address.toLowerCase() === k || s.address.toLowerCase().split("@")[1]?.replace(/:\d+$/, "") === k,
+ ]) {
+ const found = servers.filter(matches);
+ if (found.length === 1) return found[0]!;
+ if (found.length > 1) return `"${key}" matches several servers (${found.map((s) => `"${s.name}" = ${s.id}`).join(", ")}): pass the id.`;
+ }
+ return `No server "${key}" is available to this task. Available: ${names}.`;
+}
+
+function describeServers(servers: PromptSshServer[]): string {
+ return JSON.stringify(
+ servers.map((s) => ({
+ name: s.name,
+ id: s.id,
+ address: s.address,
+ os: s.os,
+ description: s.description || null,
+ sudo: s.sudoPassword ? "password saved β sudo: true works" : "no password saved β sudo: true only works without a password (NOPASSWD)",
+ })),
+ null,
+ 2,
+ );
+}
+
+const audited = new Set();
+
+export async function callSshTool(ctx: RunContext, name: string, args: unknown): Promise {
+ const run = sshRun(ctx.runId);
+ if (!run) return text("This run has no SSH servers.", true);
+ const servers = serversOf(ctx);
+ if (name === LIST_TOOL.name) return servers.length ? text(describeServers(servers)) : text("No SSH servers are available to this task anymore.", true);
+ const tool = TOOLS.find((t) => t.name === name);
+ if (!tool) throw new UnknownSshToolError(`Unknown tool: ${name}`);
+ let label = name;
+ try {
+ const parsed = tool.schema.parse(args ?? {}) as { server?: string };
+ const server = pickServer(servers, parsed.server);
+ if (typeof server === "string") return text(server, true);
+ label = `${name} on ${server.name}`;
+ const key = `${ctx.runId}:${server.id}`;
+ if (!audited.has(key)) {
+ audited.add(key);
+ setTimeout(() => audited.delete(key), 6 * 3600_000).unref?.();
+ audit(`agent:${ctx.agentId}`, "ssh.use", server.id, { runId: ctx.runId });
+ }
+ const secrets = serverSecrets(server.id);
+ try {
+ const result = await useServer(
+ server.id,
+ (conn) => tool.run(parsed as never, { server, conn, runId: ctx.runId, agentId: ctx.agentId, folders: run.folders }),
+ run.signal,
+ );
+ return { ...result, content: result.content.map((c) => ({ ...c, text: mask(c.text, secrets) })) };
+ } catch (err) {
+ if (err instanceof Error) err.message = mask(err.message, secrets);
+ throw err;
+ }
+ } catch (err) {
+ if (err instanceof z.ZodError) return text(`Invalid arguments: ${err.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ")}`, true);
+ if (err instanceof SshError) return text(err.message === "Cancelled" ? "Cancelled." : `Couldn't connect: ${err.message}`, true);
+ if (err instanceof RemoteFileError) return text(err.message, true);
+ if (err instanceof HttpError) return text(err.status === 423 ? "The vault is locked; ask the human to unlock Godmode." : err.message, true);
+ log.warn(`ssh tool ${label} failed`, err);
+ return text(`The SSH tool failed: ${err instanceof Error ? err.message : String(err)}`, true);
+ }
+}
diff --git a/packages/core/src/vault/vault.ts b/packages/core/src/vault/vault.ts
index c99a3b0a..e17ec666 100644
--- a/packages/core/src/vault/vault.ts
+++ b/packages/core/src/vault/vault.ts
@@ -346,6 +346,22 @@ function loadKnownSecrets() {
/* ignore */
}
}
+ for (const row of all<{ id: string; password_enc: string | null; private_key_enc: string | null; passphrase_enc: string | null }>(
+ "SELECT id, password_enc, private_key_enc, passphrase_enc FROM ssh_servers",
+ )) {
+ for (const [enc, field] of [
+ [row.password_enc, "password"],
+ [row.private_key_enc, "private_key"],
+ [row.passphrase_enc, "passphrase"],
+ ] as const) {
+ if (!enc) continue;
+ try {
+ rememberSecret(decrypt(dek, enc, `ssh_servers.${field}:${row.id}`));
+ } catch {
+ /* ignore */
+ }
+ }
+ }
for (const row of all<{ id: string; secrets_enc: string | null }>("SELECT id, secrets_enc FROM messaging_connections")) {
if (!row.secrets_enc) continue;
try {
diff --git a/packages/core/test/composio.test.ts b/packages/core/test/composio.test.ts
index be7d96be..a0e22eac 100644
--- a/packages/core/test/composio.test.ts
+++ b/packages/core/test/composio.test.ts
@@ -85,6 +85,7 @@ function agentModel(p: Partial & { id: string }): Agent {
subagents: [],
workingDirectory: null,
vmId: null,
+ sshServerIds: [],
repoPath: "",
lastRunAt: null,
createdAt: "",
diff --git a/packages/core/test/credentials.test.ts b/packages/core/test/credentials.test.ts
index 8c02afef..fd2e669b 100644
--- a/packages/core/test/credentials.test.ts
+++ b/packages/core/test/credentials.test.ts
@@ -85,6 +85,7 @@ function makeAgent(workspaceId: string | null, permissions: Partial }>;
+ };
+ const server = cfg.mcpServers.ssh;
+ if (!server) {
+ textTurn("no ssh server");
+ result("no ssh server");
+ } else {
+ let id = 0;
+ const rpc = async (method: string, params?: unknown) => {
+ const res = await fetch(server.url, {
+ method: "POST",
+ headers: { ...server.headers, "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, ...(params ? { params } : {}) }),
+ });
+ const raw = await res.text();
+ return raw ? JSON.parse(raw) : null;
+ };
+ const call = async (name: string, args: Record) => {
+ const r = await rpc("tools/call", { name, arguments: args });
+ return { text: r.result.content[0].text as string, isError: !!r.result.isError };
+ };
+ const initRes = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake", version: "1" } });
+ const list = await rpc("tools/list");
+ const servers = await call("list_servers", {});
+ const shell = await call("shell", { command: "echo hello-from-ssh; echo oops >&2; exit 3" });
+ const write = await call("write_file", { path: "project/notes.txt", content: "alpha\nbeta\n" });
+ const edit = await call("edit_file", { path: "project/notes.txt", old_string: "beta", new_string: "gamma" });
+ const read = await call("read_file", { path: "~/project/notes.txt" });
+ const sudo = await call("shell", { command: "echo root=$FAKE_ROOT", sudo: true });
+ const summary = {
+ server: initRes.result.serverInfo.name,
+ sameToken: server.headers.Authorization === cfg.mcpServers.godmode!.headers.Authorization,
+ tools: (list.result.tools as { name: string }[]).map((t) => t.name),
+ servers,
+ shell,
+ write,
+ edit,
+ read,
+ sudo,
+ };
+ const text = `SSH ${JSON.stringify(summary)}`;
+ textTurn(text);
+ result(text);
+ }
} else if (prompt.includes("CALL_GUEST")) {
out(init);
const cfg = JSON.parse(readFileSync(argValue("--mcp-config")!, "utf8")) as {
diff --git a/packages/core/test/fixtures/ssh-server.ts b/packages/core/test/fixtures/ssh-server.ts
new file mode 100644
index 00000000..8467472b
--- /dev/null
+++ b/packages/core/test/fixtures/ssh-server.ts
@@ -0,0 +1,263 @@
+/**
+ * An SSH server for tests, built on ssh2's server side: password and public key sign-in, commands run with `sh` in a
+ * temporary home folder, SFTP on the real file system (relative paths from that home), and a fake `sudo` on the PATH
+ * that checks the password it reads from stdin (or needs none with `nopasswd`) and marks root with FAKE_ROOT=1.
+ */
+import { closeSync, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync, writeSync, chmodSync, renameSync, unlinkSync, type Stats } from "node:fs";
+import { tmpdir } from "node:os";
+import { isAbsolute, join } from "node:path";
+import { Server, utils, type Attributes, type ParsedKey } from "ssh2";
+import { fingerprintOf } from "../../src/ssh/keys";
+
+export interface TestSshServerOptions {
+ username?: string;
+ password?: string;
+ /** Password sudo expects; null = sudo needs none (NOPASSWD). Default: the login password. */
+ sudoPassword?: string | null;
+ /** sudo needs no password for anything but \`true\` (so a \`sudo -n true\` probe fails). */
+ sudoNopasswdSome?: boolean;
+ /** OpenSSH private host key; default: a new Ed25519 key. */
+ hostKey?: string;
+ /** Offer the SFTP subsystem (default true). */
+ sftp?: boolean;
+ port?: number;
+}
+
+export interface TestSshServer {
+ port: number;
+ home: string;
+ username: string;
+ password: string;
+ hostKey: string;
+ hostKeyFingerprint: string;
+ /** Private key (OpenSSH) whose public key may sign in. */
+ userKey: string;
+ userPublicKey: string;
+ commands: string[];
+ close: () => Promise;
+}
+
+const FAKE_SUDO = `#!/bin/sh
+nopass=""; stdin_pw=""; prompt="Password:"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -n) nopass=1; shift;;
+ -S) stdin_pw=1; shift;;
+ -k) shift;;
+ -p) prompt="$2"; shift 2;;
+ --) shift; break;;
+ *) break;;
+ esac
+done
+if [ "$SUDO_MODE" = "nopasswd" ]; then FAKE_ROOT=1 exec "$@"; fi
+# NOPASSWD for everything but \`true\`: the probe fails, the command itself runs without asking.
+if [ "$SUDO_MODE" = "nopasswd-some" ] && [ "$1" != "true" ]; then FAKE_ROOT=1 exec "$@"; fi
+if [ -n "$nopass" ]; then echo "sudo: a password is required" >&2; exit 1; fi
+if [ -n "$stdin_pw" ]; then
+ printf '%s' "$prompt" >&2
+ IFS= read -r pw
+ if [ "$pw" = "$SUDO_EXPECT" ]; then FAKE_ROOT=1 exec "$@"; fi
+ echo "Sorry, try again." >&2
+ printf '%s' "$prompt" >&2
+ IFS= read -r pw || { echo "sudo: no password was provided" >&2; exit 1; }
+ echo "sudo: 2 incorrect password attempts" >&2
+ exit 1
+fi
+exit 1
+`;
+
+function attrsOf(st: Stats): Attributes {
+ return { mode: st.mode, uid: st.uid, gid: st.gid, size: st.size, atime: Math.floor(st.atimeMs / 1000), mtime: Math.floor(st.mtimeMs / 1000) };
+}
+
+export async function startSshServer(opts: TestSshServerOptions = {}): Promise {
+ const username = opts.username ?? "deploy";
+ const password = opts.password ?? "hunter2-login";
+ const sudoPassword = opts.sudoPassword === undefined ? password : opts.sudoPassword;
+ const hostKey = opts.hostKey ?? utils.generateKeyPairSync("ed25519").private;
+ const user = utils.generateKeyPairSync("ed25519", { comment: "test@godmode" });
+ const allowed = utils.parseKey(user.public) as ParsedKey;
+ const hostParsed = utils.parseKey(hostKey) as ParsedKey;
+ const root = mkdtempSync(join(tmpdir(), "godmode-sshd-"));
+ const home = join(root, "home");
+ const bin = join(root, "bin");
+ mkdirSync(home);
+ mkdirSync(bin);
+ writeFileSync(join(bin, "sudo"), FAKE_SUDO);
+ chmodSync(join(bin, "sudo"), 0o755);
+ const commands: string[] = [];
+ const clients = new Set<{ end: () => void }>();
+ const resolvePath = (p: string) => (isAbsolute(p) ? p : join(home, p));
+
+ const server = new Server({ hostKeys: [hostKey] }, (client) => {
+ clients.add(client);
+ client.on("close", () => clients.delete(client));
+ client.on("authentication", (ctx) => {
+ if (ctx.username !== username) return ctx.reject(["password", "publickey"]);
+ if (ctx.method === "password" && ctx.password === password) return ctx.accept();
+ if (ctx.method === "publickey" && ctx.key.algo === allowed.type && Buffer.compare(ctx.key.data, allowed.getPublicSSH()) === 0) {
+ if (!ctx.signature || !ctx.blob) return ctx.accept();
+ if (allowed.verify(ctx.blob, ctx.signature, ctx.hashAlgo)) return ctx.accept();
+ }
+ ctx.reject(["password", "publickey"]);
+ });
+ client.on("error", () => undefined);
+ client.on("ready", () => {
+ client.on("session", (acceptSession) => {
+ const session = acceptSession();
+ let kill: (() => void) | null = null;
+ session.on("signal", (accept) => {
+ accept?.();
+ kill?.();
+ });
+ session.on("exec", (accept, _reject, info) => {
+ const stream = accept();
+ commands.push(info.command);
+ const child = Bun.spawn(["sh", "-c", info.command], {
+ cwd: home,
+ env: {
+ HOME: home,
+ PATH: `${bin}:${process.env.PATH ?? "/usr/bin:/bin"}`,
+ SUDO_EXPECT: sudoPassword ?? "",
+ SUDO_MODE: sudoPassword === null ? "nopasswd" : opts.sudoNopasswdSome ? "nopasswd-some" : "password",
+ },
+ stdin: "pipe",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ kill = () => child.kill("SIGKILL");
+ stream.on("data", (d: Buffer) => {
+ try {
+ child.stdin.write(d);
+ } catch {
+ /* exited */
+ }
+ });
+ stream.on("end", () => void child.stdin.end());
+ stream.on("close", () => child.kill("SIGKILL"));
+ const pump = async (from: ReadableStream, to: (b: Buffer) => void) => {
+ const reader = from.getReader();
+ for (let r = await reader.read(); !r.done; r = await reader.read()) to(Buffer.from(r.value));
+ };
+ void Promise.all([pump(child.stdout, (b) => stream.write(b)), pump(child.stderr, (b) => stream.stderr.write(b)), child.exited]).then(([, , code]) => {
+ try {
+ if (child.signalCode) stream.exit(child.signalCode.replace(/^SIG/, ""), false, "");
+ else stream.exit(code);
+ stream.end();
+ } catch {
+ /* channel closed */
+ }
+ });
+ });
+ session.on("sftp", (accept, reject) => {
+ if (opts.sftp === false) return reject();
+ const sftp = accept();
+ const STATUS = utils.sftp.STATUS_CODE;
+ const handles = new Map();
+ let next = 1;
+ const handleOf = (buf: Buffer) => handles.get(buf.readUInt32BE(0));
+ const fail = (reqid: number, err: unknown) => {
+ const code = (err as { code?: string }).code;
+ sftp.status(reqid, code === "ENOENT" ? STATUS.NO_SUCH_FILE : code === "EACCES" || code === "EPERM" ? STATUS.PERMISSION_DENIED : STATUS.FAILURE);
+ };
+ sftp.on("OPEN", (reqid, filename, flags, attrs) => {
+ try {
+ const fd = openSync(resolvePath(filename), utils.sftp.flagsToString(flags) ?? "r", attrs?.mode ? attrs.mode & 0o777 : 0o644);
+ const id = next++;
+ handles.set(id, { fd });
+ const buf = Buffer.alloc(4);
+ buf.writeUInt32BE(id);
+ sftp.handle(reqid, buf);
+ } catch (err) {
+ fail(reqid, err);
+ }
+ });
+ sftp.on("READ", (reqid, handle, offset, length) => {
+ const h = handleOf(handle);
+ if (!h) return sftp.status(reqid, STATUS.FAILURE);
+ const buf = Buffer.alloc(length);
+ const n = readSync(h.fd, buf, 0, length, offset);
+ if (n === 0) return sftp.status(reqid, STATUS.EOF);
+ sftp.data(reqid, buf.subarray(0, n));
+ });
+ sftp.on("WRITE", (reqid, handle, offset, data) => {
+ const h = handleOf(handle);
+ if (!h) return sftp.status(reqid, STATUS.FAILURE);
+ writeSync(h.fd, data, 0, data.length, offset);
+ sftp.status(reqid, STATUS.OK);
+ });
+ sftp.on("CLOSE", (reqid, handle) => {
+ const id = handle.readUInt32BE(0);
+ const h = handles.get(id);
+ if (h) closeSync(h.fd);
+ handles.delete(id);
+ sftp.status(reqid, STATUS.OK);
+ });
+ sftp.on("FSTAT", (reqid, handle) => {
+ const h = handleOf(handle);
+ if (!h) return sftp.status(reqid, STATUS.FAILURE);
+ sftp.attrs(reqid, attrsOf(fstatSync(h.fd)));
+ });
+ const statReq = (reqid: number, path: string) => {
+ try {
+ sftp.attrs(reqid, attrsOf(statSync(resolvePath(path))));
+ } catch (err) {
+ fail(reqid, err);
+ }
+ };
+ sftp.on("STAT", statReq);
+ sftp.on("LSTAT", statReq);
+ sftp.on("MKDIR", (reqid, path) => {
+ try {
+ mkdirSync(resolvePath(path));
+ sftp.status(reqid, STATUS.OK);
+ } catch (err) {
+ fail(reqid, err);
+ }
+ });
+ sftp.on("REALPATH", (reqid, path) => sftp.name(reqid, [{ filename: resolvePath(path), longname: "", attrs: {} as Attributes }]));
+ sftp.on("SETSTAT", (reqid) => sftp.status(reqid, STATUS.OK));
+ sftp.on("FSETSTAT", (reqid) => sftp.status(reqid, STATUS.OK));
+ sftp.on("REMOVE", (reqid, path) => {
+ try {
+ unlinkSync(resolvePath(path));
+ sftp.status(reqid, STATUS.OK);
+ } catch (err) {
+ fail(reqid, err);
+ }
+ });
+ sftp.on("RENAME", (reqid, from, to) => {
+ try {
+ renameSync(resolvePath(from), resolvePath(to));
+ sftp.status(reqid, STATUS.OK);
+ } catch (err) {
+ fail(reqid, err);
+ }
+ });
+ });
+ });
+ });
+ });
+
+ const port = await new Promise((resolve) => server.listen(opts.port ?? 0, "127.0.0.1", () => resolve((server.address() as { port: number }).port)));
+ return {
+ port,
+ home,
+ username,
+ password,
+ hostKey,
+ hostKeyFingerprint: fingerprintOf(hostParsed.getPublicSSH()),
+ userKey: user.private,
+ userPublicKey: user.public,
+ commands,
+ close: async () => {
+ const closed = new Promise((resolve) => server.close(() => resolve()));
+ // Open connections would keep the server from closing.
+ for (const client of clients) client.end();
+ // Bun on Linux can leave a socket that both sides ended open without ever emitting "close", and server.close
+ // waits for it forever β don't let that hang the test.
+ await Promise.race([closed, Bun.sleep(1000)]);
+ rmSync(root, { recursive: true, force: true });
+ },
+ };
+}
diff --git a/packages/core/test/mcp-servers.test.ts b/packages/core/test/mcp-servers.test.ts
index 869dff9d..48a4be08 100644
--- a/packages/core/test/mcp-servers.test.ts
+++ b/packages/core/test/mcp-servers.test.ts
@@ -57,6 +57,7 @@ function agentModel(p: Partial & { id: string }): Agent {
subagents: [],
workingDirectory: null,
vmId: null,
+ sshServerIds: [],
repoPath: "",
lastRunAt: null,
createdAt: "",
diff --git a/packages/core/test/scheduler.test.ts b/packages/core/test/scheduler.test.ts
index 2e2f59ed..9bc7245f 100644
--- a/packages/core/test/scheduler.test.ts
+++ b/packages/core/test/scheduler.test.ts
@@ -147,6 +147,7 @@ describe("triggering", () => {
vmId: null,
browserProfileId: null,
workspaceId: null,
+ sshServerIds: [],
instructions: "",
pinned: false,
archived: false,
diff --git a/packages/core/test/ssh.test.ts b/packages/core/test/ssh.test.ts
new file mode 100644
index 00000000..702e84e5
--- /dev/null
+++ b/packages/core/test/ssh.test.ts
@@ -0,0 +1,429 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { utils } from "ssh2";
+import type { Agent, SshServer } from "@godmode/shared";
+import { argValue, invocations, makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness";
+import { startSshServer, type TestSshServer } from "./fixtures/ssh-server";
+import * as vault from "../src/vault/vault";
+import { getAgent, updateAgent } from "../src/agents/service";
+import { createConversation, getConversationSummary, sendMessage, startChat, updateConversation } from "../src/services/conversations";
+import { waitForRun } from "../src/runner/runner";
+import { issueRunToken, revokeRunToken } from "../src/mcp/tokens";
+import { listAudit } from "../src/services/audit";
+import { get } from "../src/db";
+import { assignServer, attachSsh, createServer, deleteServer, detachSsh, execForHuman, getServer, listServers, testServer, tryServer, updateServer } from "../src/ssh/service";
+import { closeAllConnections } from "../src/ssh/client";
+import { listLocalKeys } from "../src/ssh/keys";
+
+const PASSPHRASE = "correct horse battery staple";
+
+let env: TestEnv;
+let sshd: TestSshServer;
+let agent: Agent;
+
+beforeAll(async () => {
+ env = await setupEnv("godmode-ssh-");
+ await vault.setup(PASSPHRASE, false);
+ sshd = await startSshServer({ password: "hunter2-login" });
+ agent = await makeAgent({ name: "Operator" });
+});
+
+afterAll(async () => {
+ closeAllConnections();
+ await sshd.close();
+ await env.close();
+});
+
+function passwordServer(extra: Partial[0]> = {}): SshServer {
+ return createServer({ name: "Web", host: "127.0.0.1", port: sshd.port, username: sshd.username, auth: "password", password: sshd.password, ...extra });
+}
+
+describe("servers", () => {
+ test("secrets are sealed and never returned", () => {
+ const server = passwordServer({ description: " Production web " });
+ expect(server).toMatchObject({ name: "Web", host: "127.0.0.1", port: sshd.port, auth: "password", hasPassword: true, key: null, description: "Production web", hostKey: null });
+ expect(JSON.stringify(server)).not.toContain(sshd.password);
+ const row = get<{ password_enc: string }>("SELECT password_enc FROM ssh_servers WHERE id = ?", server.id)!;
+ expect(row.password_enc).not.toContain(sshd.password);
+ expect(vault.redact(`the password is ${sshd.password}`)).not.toContain(sshd.password);
+ deleteServer(server.id);
+ });
+
+ test("validates the address and requires a secret", () => {
+ expect(() => passwordServer({ host: "deploy@example.com" })).toThrow(/host name or IP address/);
+ expect(() => passwordServer({ port: 70000 })).toThrow(/port/);
+ expect(() => passwordServer({ username: "two words" })).toThrow(/user name/);
+ expect(() => passwordServer({ password: "" })).toThrow(/Enter the password/);
+ expect(() => createServer({ name: "K", host: "example.com", username: "root", auth: "key" })).toThrow(/private key/);
+ expect(passwordServer({ host: "[::1]" }).host).toBe("::1");
+ });
+
+ test("keys: public keys are refused, a passphrase is required and checked", () => {
+ const encrypted = utils.generateKeyPairSync("ed25519", { passphrase: "open sesame", cipher: "aes256-ctr", rounds: 4 });
+ const base = { name: "Keyed", host: "example.com", username: "root", auth: "key" as const };
+ expect(() => createServer({ ...base, privateKey: sshd.userPublicKey })).toThrow(/public key/);
+ expect(() => createServer({ ...base, privateKey: encrypted.private })).toThrow(/passphrase/);
+ expect(() => createServer({ ...base, privateKey: encrypted.private, passphrase: "wrong" })).toThrow(/doesn't unlock/);
+ const server = createServer({ ...base, privateKey: encrypted.private, passphrase: "open sesame", password: "sudo-secret-1" });
+ expect(server.key).toMatchObject({ type: "ssh-ed25519", encrypted: true });
+ expect(server.key!.publicKey.startsWith("ssh-ed25519 AAAA")).toBe(true);
+ expect(server.hasPassword).toBe(true);
+ // Only the sudo password changes: the key stays.
+ const updated = updateServer(server.id, { password: "sudo-secret-2" });
+ expect(updated.key).toEqual(server.key);
+ // Switching to a password login drops the key.
+ expect(updateServer(server.id, { auth: "password" }).key).toBeNull();
+ deleteServer(server.id);
+ });
+
+ test("moving a server forgets its host key", () => {
+ const server = passwordServer({ hostKey: { type: "ssh-ed25519", fingerprint: sshd.hostKeyFingerprint } });
+ expect(server.hostKey?.fingerprint).toBe(sshd.hostKeyFingerprint);
+ expect(updateServer(server.id, { name: "Renamed" }).hostKey).not.toBeNull();
+ expect(updateServer(server.id, { host: "localhost" }).hostKey).toBeNull();
+ expect(() => updateServer(server.id, { hostKey: { type: "x", fingerprint: "MD5:nope" } })).toThrow(/fingerprint/);
+ deleteServer(server.id);
+ });
+
+ test("keys from ~/.ssh can be listed and imported", () => {
+ const home = mkdtempSync(join(tmpdir(), "godmode-home-"));
+ const previous = process.env.HOME;
+ process.env.HOME = home;
+ try {
+ mkdirSync(join(home, ".ssh"));
+ writeFileSync(join(home, ".ssh", "id_ed25519"), sshd.userKey);
+ writeFileSync(join(home, ".ssh", "id_ed25519.pub"), sshd.userPublicKey);
+ writeFileSync(join(home, ".ssh", "known_hosts"), "example.com ssh-ed25519 AAAA\n");
+ writeFileSync(join(home, ".ssh", "config"), "Host *\n");
+ const keys = listLocalKeys();
+ expect(keys.map((k) => k.name)).toEqual(["id_ed25519"]);
+ expect(keys[0]).toMatchObject({ type: "ssh-ed25519", encrypted: false, comment: "test@godmode" });
+ const server = createServer({ name: "Imported", host: "127.0.0.1", port: sshd.port, username: sshd.username, auth: "key", privateKeyPath: keys[0]!.path });
+ expect(server.key?.fingerprint).toBe(keys[0]!.fingerprint);
+ expect(() => createServer({ name: "Nope", host: "h", username: "u", auth: "key", privateKeyPath: "/etc/hosts" })).toThrow(/~\/.ssh/);
+ deleteServer(server.id);
+ } finally {
+ process.env.HOME = previous;
+ rmSync(home, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("connections", () => {
+ test("a test signs in, pins the host key and reads the OS", async () => {
+ const server = passwordServer();
+ const result = await testServer(server.id);
+ expect(result).toMatchObject({ ok: true, error: null, stage: null, hostKeyChanged: false });
+ expect(result.hostKey?.fingerprint).toBe(sshd.hostKeyFingerprint);
+ expect(result.latencyMs).toBeGreaterThanOrEqual(0);
+ const saved = getServer(server.id);
+ expect(saved.hostKey?.fingerprint).toBe(sshd.hostKeyFingerprint);
+ expect(saved.lastConnectedAt).not.toBeNull();
+ expect(saved.os).toBeTruthy();
+ deleteServer(server.id);
+ });
+
+ test("key sign-in works; a wrong password, a changed host key and a closed port are explained", async () => {
+ const keyed = createServer({ name: "Key login", host: "127.0.0.1", port: sshd.port, username: sshd.username, auth: "key", privateKey: sshd.userKey });
+ expect((await testServer(keyed.id)).ok).toBe(true);
+
+ const wrong = passwordServer({ password: "not-the-password" });
+ const denied = await testServer(wrong.id);
+ expect(denied).toMatchObject({ ok: false, stage: "auth" });
+ expect(denied.error).toContain(`didn't accept the password for "${sshd.username}"`);
+ expect(getServer(wrong.id).lastError).toBe(denied.error);
+
+ const pinned = passwordServer({ hostKey: { type: "ssh-ed25519", fingerprint: `SHA256:${"A".repeat(43)}` } });
+ const changed = await testServer(pinned.id);
+ expect(changed).toMatchObject({ ok: false, stage: "host-key", hostKeyChanged: true });
+ expect(changed.hostKey?.fingerprint).toBe(sshd.hostKeyFingerprint);
+ expect(changed.error).toContain("host key");
+ // Forgetting the key lets the next connection pin the real one.
+ updateServer(pinned.id, { hostKey: null });
+ expect((await testServer(pinned.id)).ok).toBe(true);
+
+ const closed = await tryServer({ name: "Closed", host: "127.0.0.1", port: 1, username: "x", auth: "password", password: "p" });
+ expect(closed).toMatchObject({ ok: false, stage: "connect" });
+ expect(closed.error).toContain("refused");
+
+ for (const s of [keyed, wrong, pinned]) deleteServer(s.id);
+ });
+
+ test("trying settings uses the saved secrets and records nothing", async () => {
+ const server = passwordServer();
+ const result = await tryServer({ id: server.id, name: "Web", host: "127.0.0.1", port: sshd.port, username: sshd.username, auth: "password" });
+ expect(result.ok).toBe(true);
+ expect(getServer(server.id)).toMatchObject({ hostKey: null, lastConnectedAt: null });
+ const bad = await tryServer({ name: "x", host: "bad host", username: "u", auth: "password", password: "p" });
+ expect(bad).toMatchObject({ ok: false, stage: "config" });
+ deleteServer(server.id);
+ });
+
+ test("the human can run a command; timeouts stop it", async () => {
+ const server = passwordServer();
+ const res = await execForHuman(server.id, { command: "echo hi; echo err >&2; exit 3" });
+ expect(res).toMatchObject({ exitCode: 3, stdout: "hi\n", stderr: "err\n", timedOut: false });
+ const slow = await execForHuman(server.id, { command: "sleep 5; echo late", timeoutSeconds: 1 });
+ expect(slow.timedOut).toBe(true);
+ expect(slow.stdout).not.toContain("late");
+ deleteServer(server.id);
+ });
+});
+
+describe("ssh MCP tools", () => {
+ let web: SshServer;
+ let db: SshServer;
+ let folder: string;
+
+ async function rpc(token: string, method: string, params?: unknown) {
+ const res = await fetch(`${env.baseUrl}/mcp/ssh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) }),
+ });
+ return (await res.json()) as { result: { content: { text: string }[]; isError?: boolean; tools?: { name: string }[] } };
+ }
+
+ async function call(token: string, name: string, args: Record = {}) {
+ const r = await rpc(token, "tools/call", { name, arguments: args });
+ return { text: r.result.content[0]!.text, isError: !!r.result.isError };
+ }
+
+ function runWith(sshServerIds: string[], folders: string[] = [folder]) {
+ const conv = createConversation({ agentId: agent.id, sshServerIds });
+ const runId = `run_ssh_${Math.random().toString(36).slice(2)}`;
+ attachSsh(runId, folders);
+ const token = issueRunToken({ runId, agentId: agent.id, conversationId: conv.id, workspaceId: null, depth: 0 });
+ return { token, conv, done: () => (revokeRunToken(token), detachSsh(runId)) };
+ }
+
+ beforeAll(() => {
+ web = passwordServer({ name: "web", description: "nginx" });
+ db = createServer({ name: "db", host: "127.0.0.1", port: sshd.port, username: sshd.username, auth: "key", privateKey: sshd.userKey });
+ folder = mkdtempSync(join(tmpdir(), "godmode-ssh-run-"));
+ });
+
+ afterAll(() => {
+ deleteServer(web.id);
+ deleteServer(db.id);
+ rmSync(folder, { recursive: true, force: true });
+ });
+
+ test("shell, files and sudo on the chat's server", async () => {
+ const run = runWith([web.id]);
+ try {
+ const list = await rpc(run.token, "tools/list");
+ expect(list.result.tools!.map((t) => t.name)).toEqual(["list_servers", "shell", "read_file", "write_file", "edit_file", "upload", "download"]);
+ expect(JSON.parse((await call(run.token, "list_servers")).text)[0]).toMatchObject({ name: "web", address: `${sshd.username}@127.0.0.1:${sshd.port}`, description: "nginx" });
+
+ const shell = await call(run.token, "shell", { command: "echo hello; echo oops >&2; exit 3" });
+ expect(shell.isError).toBe(true);
+ expect(shell.text).toBe("Exit code: 3\nhello\n[stderr]\noops");
+ mkdirSync(join(sshd.home, "app"));
+ expect((await call(run.token, "shell", { command: "pwd", cwd: "app" })).text).toBe(`Exit code: 0\n${realpathSync(join(sshd.home, "app"))}`);
+ expect((await call(run.token, "shell", { command: "cat", stdin: "piped input" })).text).toBe("Exit code: 0\npiped input");
+ const missing = await call(run.token, "shell", { command: "echo first; echo second", cwd: "no-such-dir" });
+ expect(missing.isError).toBe(true);
+ expect(missing.text).not.toContain("second");
+
+ expect(await call(run.token, "write_file", { path: "app/config.ini", content: "port=80\nhost=a\n" })).toEqual({ text: "Wrote 15 bytes to app/config.ini on web.", isError: false });
+ expect((await call(run.token, "edit_file", { path: "~/app/config.ini", old_string: "port=80", new_string: "port=8080" })).isError).toBe(false);
+ expect((await call(run.token, "read_file", { path: "app/config.ini" })).text).toBe("1\tport=8080\n2\thost=a");
+ expect(readFileSync(join(sshd.home, "app", "config.ini"), "utf8")).toBe("port=8080\nhost=a\n");
+ expect((await call(run.token, "read_file", { path: "app/missing.txt" })).text).toContain("No such file");
+
+ // sudo: Godmode answers the prompt; the password never shows up in what the model gets.
+ const sudo = await call(run.token, "shell", { command: "echo root=$FAKE_ROOT; cat", sudo: true, stdin: "after the password" });
+ expect(sudo).toEqual({ text: "Exit code: 0\nroot=1\nafter the password", isError: false });
+ expect((await call(run.token, "shell", { command: `echo ${sshd.password}` })).text).toBe("Exit code: 0\nβ’β’β’β’β’β’β’β’");
+ writeFileSync(join(sshd.home, "leak.txt"), `pw=${sshd.password}\n`);
+ expect((await call(run.token, "read_file", { path: "leak.txt" })).text).toBe("1\tpw=β’β’β’β’β’β’β’β’");
+ expect(listAudit(50, "ssh.").map((a) => a.action)).toEqual(expect.arrayContaining(["ssh.use", "ssh.sudo"]));
+ } finally {
+ run.done();
+ }
+ });
+
+ test("the agent's servers join the chat's; server picks the one to use", async () => {
+ await updateAgent(agent.id, { sshServerIds: [db.id] });
+ const run = runWith([web.id]);
+ try {
+ expect((await call(run.token, "shell", { command: "true" })).text).toContain('Several servers are available ("web", "db")');
+ expect((await call(run.token, "shell", { server: "DB", command: "echo key-login" })).text).toBe("Exit code: 0\nkey-login");
+ expect((await call(run.token, "shell", { server: db.id, command: "true" })).isError).toBe(false);
+ expect((await call(run.token, "shell", { server: "nope", command: "true" })).text).toContain('No server "nope"');
+ expect((await call(run.token, "shell", { server: "we", command: "echo prefix" })).text).toBe("Exit code: 0\nprefix");
+ expect((await call(run.token, "shell", { server: "127.0.0.1", command: "true" })).text).toContain("matches several servers");
+ // sudo without a saved password only works when sudo asks for none.
+ expect((await call(run.token, "shell", { server: "db", command: "id", sudo: true })).text).toContain("none is saved for this server");
+
+ // Taking a server away applies to the running run at once.
+ updateConversation(run.conv.id, { sshServerIds: [] });
+ expect((await call(run.token, "shell", { server: "web", command: "true" })).text).toContain('No server "web"');
+ } finally {
+ run.done();
+ await updateAgent(agent.id, { sshServerIds: [] });
+ }
+ });
+
+ test("uploads and downloads stay within the run's folders", async () => {
+ const run = runWith([web.id]);
+ try {
+ const bytes = Buffer.from([0, 1, 2, 250, 251, 252]);
+ writeFileSync(join(folder, "build.bin"), bytes);
+ expect((await call(run.token, "upload", { local_path: "build.bin", remote_path: "releases/" })).text).toBe("Uploaded build.bin (6 bytes) to releases/build.bin on web.");
+ expect(readFileSync(join(sshd.home, "releases", "build.bin"))).toEqual(bytes);
+ const down = await call(run.token, "download", { remote_path: "releases/build.bin", local_path: "copies/build.bin" });
+ expect(down.isError).toBe(false);
+ expect(readFileSync(join(folder, "copies", "build.bin"))).toEqual(bytes);
+ expect((await call(run.token, "download", { remote_path: "releases/build.bin", local_path: "/tmp/elsewhere.bin" })).text).toContain("outside the folders of this run");
+ expect((await call(run.token, "upload", { local_path: "../../etc/hosts" })).text).toContain("outside the folders of this run");
+ expect(existsSync("/tmp/elsewhere.bin")).toBe(false);
+ } finally {
+ run.done();
+ }
+ });
+
+ test("sudo: the password only answers sudo's own prompt, and a rejected one is reported", async () => {
+ const partial = await startSshServer({ sudoNopasswdSome: true });
+ const strict = await startSshServer({ sudoPassword: "not-the-login-password" });
+ const a = createServer({ name: "partial", host: "127.0.0.1", port: partial.port, username: partial.username, auth: "password", password: partial.password });
+ const b = createServer({ name: "strict", host: "127.0.0.1", port: strict.port, username: strict.username, auth: "password", password: strict.password });
+ const run = runWith([a.id, b.id]);
+ try {
+ // The probe fails, yet sudo doesn't ask for this command: the saved password must not become its input.
+ expect(await call(run.token, "shell", { server: "partial", command: "echo root=$FAKE_ROOT; cat", sudo: true, stdin: "only stdin" })).toEqual({
+ text: "Exit code: 0\nroot=1\nonly stdin",
+ isError: false,
+ });
+ const rejected = await call(run.token, "shell", { server: "strict", command: "id", sudo: true });
+ expect(rejected.isError).toBe(true);
+ expect(rejected.text).toContain("sudo rejected the saved password");
+ expect(rejected.text).not.toContain("godmode-sudo-");
+ } finally {
+ run.done();
+ deleteServer(a.id);
+ deleteServer(b.id);
+ await partial.close();
+ await strict.close();
+ }
+ });
+
+ test("downloads never follow dangling links or write into .git and .claude", async () => {
+ const run = runWith([web.id]);
+ try {
+ writeFileSync(join(sshd.home, "payload.txt"), "data\n");
+ const outside = join(tmpdir(), `godmode-outside-${Date.now()}.txt`);
+ symlinkSync(outside, join(folder, "dangling.txt"));
+ expect((await call(run.token, "download", { remote_path: "payload.txt", local_path: "dangling.txt" })).text).toContain("link to a file that doesn't exist");
+ expect(existsSync(outside)).toBe(false);
+ for (const target of [".git/hooks/pre-commit", ".claude/settings.json", "sub/.git/config"]) {
+ expect((await call(run.token, "download", { remote_path: "payload.txt", local_path: target })).text).toContain(".git or .claude");
+ }
+ writeFileSync(join(folder, "payload.txt"), "old\n");
+ expect((await call(run.token, "download", { remote_path: "payload.txt", local_path: "payload.txt" })).isError).toBe(false);
+ expect(readFileSync(join(folder, "payload.txt"), "utf8")).toBe("data\n");
+ expect(readdirSync(folder).filter((f) => f.endsWith(".part"))).toEqual([]);
+ } finally {
+ run.done();
+ }
+ });
+
+ test("the saved key is masked in results, line by line", async () => {
+ const run = runWith([db.id]);
+ try {
+ writeFileSync(join(sshd.home, "copied-key"), sshd.userKey);
+ const read = await call(run.token, "read_file", { path: "copied-key" });
+ const lines = sshd.userKey.split("\n").filter((l) => l.length >= 20 && !l.startsWith("-----"));
+ expect(lines.length).toBeGreaterThan(0);
+ for (const line of lines) expect(read.text).not.toContain(line);
+ expect(read.text).toContain("β’β’β’β’β’β’β’β’");
+ } finally {
+ run.done();
+ }
+ });
+
+ test("runs without SSH servers get no tools", async () => {
+ const conv = createConversation({ agentId: agent.id });
+ const token = issueRunToken({ runId: "run_ssh_none", agentId: agent.id, conversationId: conv.id, workspaceId: null, depth: 0 });
+ try {
+ expect((await rpc(token, "tools/list")).result.tools).toEqual([]);
+ expect((await call(token, "shell", { command: "true" })).text).toBe("This run has no SSH servers.");
+ } finally {
+ revokeRunToken(token);
+ }
+ });
+
+ test("servers without SFTP: text files go through the shell", async () => {
+ const plain = await startSshServer({ sftp: false, sudoPassword: null });
+ const server = createServer({ name: "no-sftp", host: "127.0.0.1", port: plain.port, username: plain.username, auth: "password", password: plain.password });
+ const run = runWith([server.id]);
+ try {
+ expect((await call(run.token, "write_file", { path: "notes/a.txt", content: "one\n" })).isError).toBe(false);
+ expect((await call(run.token, "read_file", { path: "notes/a.txt" })).text).toBe("1\tone");
+ expect((await call(run.token, "upload", { local_path: "build.bin" })).text).toContain("doesn't offer SFTP");
+ expect((await call(run.token, "shell", { command: "echo root=$FAKE_ROOT", sudo: true })).text).toBe("Exit code: 0\nroot=1");
+ } finally {
+ run.done();
+ deleteServer(server.id);
+ await plain.close();
+ }
+ });
+});
+
+describe("assignments and runs", () => {
+ test("a chat's servers reach the run: MCP config, system prompt and resumed turns", async () => {
+ const server = passwordServer({ name: "prod-web", description: "Production web server" });
+ const worker = await makeAgent({ name: "SSH Runner" });
+ const first = await startChat({ agentId: worker.id, content: "CALL_SSH", sshServerIds: [server.id] });
+ expect(first.conversation.sshServerIds).toEqual([server.id]);
+ const done = await waitForRun(first.run.id, 60_000);
+ expect(done.error).toBeNull();
+ const summary = JSON.parse(done.result!.replace(/^SSH /, ""));
+ expect(summary.server).toBe("ssh");
+ expect(summary.sameToken).toBe(true);
+ expect(summary.shell.text).toContain("Exit code: 3");
+ expect(summary.read.text).toBe("1\talpha\n2\tgamma");
+ expect(summary.sudo.text).toBe("Exit code: 0\nroot=1");
+
+ const inv = invocations(env).filter((i) => i.prompt.includes("CALL_SSH")).pop()!;
+ const prompt = argValue(inv, "--append-system-prompt")!;
+ expect(prompt).toContain("### SSH servers");
+ expect(prompt).toContain(`**prod-web** β \`${sshd.username}@127.0.0.1:${sshd.port}\``);
+ expect(prompt).toContain("Production web server");
+ expect(prompt).not.toContain(sshd.password);
+
+ const again = await waitForRun((await sendMessage(first.conversation.id, { content: "hello again" })).run.id, 30_000);
+ expect(again.status).toBe("succeeded");
+ expect(invocations(env).filter((i) => i.prompt.includes("hello again")).pop()!.prompt).toContain("SSH servers you may use with the `ssh` MCP tools");
+
+ // Without servers there's no ssh MCP server.
+ const plain = await startChat({ agentId: worker.id, content: "CALL_SSH" });
+ expect((await waitForRun(plain.run.id, 30_000)).result).toBe("no ssh server");
+ deleteServer(server.id);
+ });
+
+ test("assigning, agents can't grant themselves servers, deleting removes everywhere", async () => {
+ const server = passwordServer({ name: "shared" });
+ const worker = await makeAgent({ name: "Assignee" });
+ const conv = createConversation({ agentId: worker.id });
+ await assignServer(server.id, { kind: "agent", id: worker.id, assigned: true });
+ const assigned = await assignServer(server.id, { kind: "conversation", id: conv.id, assigned: true });
+ expect(assigned.assignments.map((a) => a.kind).sort()).toEqual(["agent", "conversation"]);
+ expect(getAgent(worker.id).sshServerIds).toEqual([server.id]);
+
+ const other = passwordServer({ name: "other" });
+ await updateAgent(worker.id, { sshServerIds: [server.id, other.id] }, `agent:${worker.id}`);
+ expect(getAgent(worker.id).sshServerIds).toEqual([server.id]);
+ // A server deleted meanwhile is dropped instead of blocking the change.
+ expect((await updateAgent(worker.id, { sshServerIds: ["ssh_missing", server.id] })).sshServerIds).toEqual([server.id]);
+
+ deleteServer(server.id);
+ expect(getAgent(worker.id).sshServerIds).toEqual([]);
+ expect(getConversationSummary(conv.id).sshServerIds).toEqual([]);
+ expect(listServers().map((s) => s.id)).toEqual(expect.not.arrayContaining([server.id]));
+ deleteServer(other.id);
+ });
+});
diff --git a/packages/core/test/totp.test.ts b/packages/core/test/totp.test.ts
index 648c7875..deaf4c66 100644
--- a/packages/core/test/totp.test.ts
+++ b/packages/core/test/totp.test.ts
@@ -69,6 +69,7 @@ function makeAgent(workspaceId: string | null, permissions: Partial;
+
+/** POST /api/ssh/test: try a connection before saving. With `id`, secrets the input leaves out come from that server. */
+export interface SshTestInput extends SshServerInput {
+ id?: ID;
+}
+
+/** Where a connection attempt stopped. */
+export type SshTestStage = "config" | "connect" | "host-key" | "auth" | "command";
+
+export interface SshTestResult {
+ ok: boolean;
+ /** Human-readable reason when the test failed. */
+ error: string | null;
+ stage: SshTestStage | null;
+ /** The key the server presented (also when the test failed after the handshake). */
+ hostKey: SshHostKey | null;
+ /** The server presented a different key than the pinned one. */
+ hostKeyChanged: boolean;
+ os: string | null;
+ /** Connect, handshake and sign-in. */
+ latencyMs: number | null;
+}
+
+/** A private key found in ~/.ssh on the computer running Godmode (GET /api/ssh/local-keys). */
+export interface SshLocalKey {
+ path: string;
+ name: string;
+ type: string;
+ fingerprint: string;
+ encrypted: boolean;
+ comment: string;
+}
+
+/** POST /api/ssh/keys: a new Ed25519 key pair. */
+export interface SshGeneratedKey {
+ privateKey: string;
+ publicKey: string;
+ type: string;
+ fingerprint: string;
+}
+
+/** POST /api/ssh/servers/:id/exec */
+export interface SshExecInput {
+ command: string;
+ timeoutSeconds?: number;
+}
+
+export interface SshExecResult {
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+ timedOut: boolean;
+ durationMs: number;
+}
+
+/** POST /api/ssh/servers/:id/assign */
+export interface SshAssignInput {
+ kind: SshAssignmentKind;
+ id: ID;
+ /** false = remove the assignment. */
+ assigned: boolean;
+}
+
+/** `ssh -p user@host` for a terminal (the port only when it isn't 22). */
+export function sshCommand(server: Pick): string {
+ return `ssh ${server.port !== 22 ? `-p ${server.port} ` : ""}${server.username}@${server.host}`;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d5cfe26c..9378db42 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -216,6 +216,9 @@ importers:
isomorphic-git:
specifier: ^1.42.3
version: 1.42.3
+ ssh2:
+ specifier: ^1.17.0
+ version: 1.17.0
zod:
specifier: ^4.6.5
version: 4.6.5
@@ -223,6 +226,9 @@ importers:
'@types/bun':
specifier: ^1.3.14
version: 1.4.2
+ '@types/ssh2':
+ specifier: ^1.15.6
+ version: 1.15.6
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -2338,6 +2344,9 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
+ '@types/node@18.19.130':
+ resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
+
'@types/node@24.19.0':
resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==}
@@ -2355,6 +2364,9 @@ packages:
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
+ '@types/ssh2@1.15.6':
+ resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -2456,6 +2468,9 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
+ asn1@0.2.6:
+ resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
+
astro@7.3.5:
resolution: {integrity: sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg==}
engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
@@ -2483,6 +2498,9 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ bcrypt-pbkdf@1.0.2:
+ resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
+
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
@@ -2492,6 +2510,10 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+ buildcheck@0.0.7:
+ resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
+ engines: {node: '>=10.0.0'}
+
bun-types@1.4.2:
resolution: {integrity: sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==}
@@ -2581,6 +2603,10 @@ packages:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
+ cpu-features@0.0.10:
+ resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
+ engines: {node: '>=10.0.0'}
+
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@@ -3325,6 +3351,9 @@ packages:
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+ nan@2.29.0:
+ resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==}
+
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3593,6 +3622,9 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
satteri@0.10.5:
resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==}
@@ -3677,6 +3709,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ ssh2@1.17.0:
+ resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
+ engines: {node: '>=10.16.0'}
+
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
@@ -3760,6 +3796,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
+ tweetnacl@0.14.5:
+ resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -3784,6 +3823,9 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+ undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
@@ -5993,6 +6035,10 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/node@18.19.130':
+ dependencies:
+ undici-types: 5.26.5
+
'@types/node@24.19.0':
dependencies:
undici-types: 7.24.6
@@ -6013,6 +6059,10 @@ snapshots:
dependencies:
'@types/node': 26.6.3
+ '@types/ssh2@1.15.6':
+ dependencies:
+ '@types/node': 18.19.130
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -6116,6 +6166,10 @@ snapshots:
aria-query@5.3.2: {}
+ asn1@0.2.6:
+ dependencies:
+ safer-buffer: 2.1.2
+
astro@7.3.5(@types/node@26.6.3)(jiti@2.7.0)(yaml@2.9.1):
dependencies:
'@astrojs/compiler-rs': 0.5.1
@@ -6217,6 +6271,10 @@ snapshots:
base64-js@1.5.1: {}
+ bcrypt-pbkdf@1.0.2:
+ dependencies:
+ tweetnacl: 0.14.5
+
blake3-wasm@2.1.5: {}
boolbase@1.0.0: {}
@@ -6226,6 +6284,9 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ buildcheck@0.0.7:
+ optional: true
+
bun-types@1.4.2:
dependencies:
'@types/node': 26.6.3
@@ -6307,6 +6368,12 @@ snapshots:
cookie@2.0.1: {}
+ cpu-features@0.0.10:
+ dependencies:
+ buildcheck: 0.0.7
+ nan: 2.29.0
+ optional: true
+
crc-32@1.2.2: {}
croner@10.0.1: {}
@@ -7249,6 +7316,9 @@ snapshots:
muggle-string@0.4.1: {}
+ nan@2.29.0:
+ optional: true
+
nanoid@3.3.19: {}
neotraverse@1.0.1: {}
@@ -7580,6 +7650,8 @@ snapshots:
safe-buffer@5.2.1: {}
+ safer-buffer@2.1.2: {}
+
satteri@0.10.5:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -7727,6 +7799,14 @@ snapshots:
space-separated-tokens@2.0.2: {}
+ ssh2@1.17.0:
+ dependencies:
+ asn1: 0.2.6
+ bcrypt-pbkdf: 1.0.2
+ optionalDependencies:
+ cpu-features: 0.0.10
+ nan: 2.29.0
+
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
@@ -7806,6 +7886,8 @@ snapshots:
tw-animate-css@1.4.0: {}
+ tweetnacl@0.14.5: {}
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -7826,6 +7908,8 @@ snapshots:
uncrypto@0.1.3: {}
+ undici-types@5.26.5: {}
+
undici-types@7.24.6: {}
undici-types@8.9.0: {}