Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -140,6 +141,10 @@ needs to be useful:
<td><img src="docs/screenshots/api-tools.png" alt="API tools with their keys, scopes and addresses" /><br /><sub><b>Tools</b> — APIs agents use with your keys, scoped globally, per workspace or per agent</sub></td>
<td><img src="docs/screenshots/api-tool-dialog.png" alt="Add Nano Banana: name, what it's for and the API key" /><br /><sub><b>Add a tool</b> — presets bring the address and docs, you add the key</sub></td>
</tr>
<tr>
<td><img src="docs/screenshots/ssh-servers.png" alt="SSH servers with their connection status, pinned host keys and the agents and chats using them" /><br /><sub><b>SSH servers</b> — password or key, sealed in the vault; host keys pinned on the first connection</sub></td>
<td><img src="docs/screenshots/ssh-chat.png" alt="An agent reading a config and a log on a server over SSH and changing the config after backing it up" /><br /><sub><b>Work on servers</b> — pick servers for a chat; the agent runs commands and edits files there</sub></td>
</tr>
</table>

## 📦 Install
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down Expand Up @@ -116,6 +117,7 @@ export function App() {
<Route path="/browser" element={<BrowserPage />} />
<Route path="/computer" element={<ComputerPage />} />
<Route path="/vms" element={<VmsPage />} />
<Route path="/ssh" element={<SshPage />} />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/settings" element={<Navigate to="/settings/general" replace />} />
<Route path="/settings/:section" element={<SettingsPage />} />
Expand Down
71 changes: 70 additions & 1 deletion apps/desktop/src/components/agents/agent-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +14,7 @@ import {
MonitorUp,
Plug,
Plus,
Server,
ShieldCheck,
Sparkles,
Trash2,
Expand All @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -111,6 +116,7 @@ export function agentToValues(
subagents: source?.subagents ?? [],
workingDirectory: source?.workingDirectory ?? null,
vmId: source?.vmId ?? null,
sshServerIds: source?.sshServerIds ?? [],
};
}

Expand Down Expand Up @@ -141,6 +147,7 @@ export function valuesToInput(v: AgentFormValues): AgentInput {
.filter((s) => s.name),
workingDirectory: v.workingDirectory,
vmId: v.vmId,
sshServerIds: v.sshServerIds,
};
}

Expand Down Expand Up @@ -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" },
];
Expand Down Expand Up @@ -585,6 +593,10 @@ export function AgentForm({
</FormSection>
)}

<FormSection id="ssh" title="SSH servers" description="Remote machines this agent can sign in to and control. Godmode types the password or key — the AI never sees it.">
<SshField value={values.sshServerIds} onChange={(v) => set("sshServerIds", v)} />
</FormSection>

<FormSection id="tools" title="Tools & integrations" description="APIs, MCP servers and connected apps this agent can use.">
<div className="space-y-5">
<ToggleRow
Expand Down Expand Up @@ -645,6 +657,12 @@ export function AgentForm({
<span className="truncate">{vmChoices.vms.find((v) => v.id === values.vmId)?.name ?? "VM"}</span>
</span>
)}
{values.sshServerIds.length > 0 && (
<span className="flex items-center gap-1 rounded-[5px] border bg-secondary px-1.5 py-0.5">
<Server className="size-3 shrink-0" />
{values.sshServerIds.length === 1 ? "1 server" : `${values.sshServerIds.length} servers`}
</span>
)}
{values.workingDirectory && (
<span className="flex max-w-full items-center gap-1 rounded-[5px] border bg-secondary px-1.5 py-0.5">
<FolderOpen className="size-3 shrink-0" />
Expand Down Expand Up @@ -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 (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-dashed px-4 py-3.5">
<Server className="size-5 shrink-0 text-muted-foreground" />
<p className="min-w-0 flex-1 basis-48 text-sm text-muted-foreground">No SSH servers yet — add one and this agent can work on it.</p>
<Button type="button" variant="outline" size="sm" asChild>
<Link to="/ssh?new=1">
<Plus /> Add a server
</Link>
</Button>
</div>
);
}
const toggle = (id: string, on: boolean) => onChange(on ? [...value.filter((x) => x !== id), id] : value.filter((x) => x !== id));
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span id="agent-ssh-label" className="text-sm font-medium">
Can sign in to
</span>
<Link to="/ssh" className="flex items-center gap-1 text-xs text-muted-foreground transition hover:text-foreground">
Manage servers <ArrowRight className="size-3" />
</Link>
</div>
<div role="group" aria-labelledby="agent-ssh-label" className="divide-y overflow-hidden rounded-lg border">
{isLoading
? [0, 1].map((i) => <div key={i} className="h-[52px] animate-pulse bg-paper-2/60" />)
: servers.map((s) => {
const id = `agent-ssh-${s.id}`;
return (
<label key={s.id} htmlFor={id} className="flex cursor-pointer items-center gap-3 px-3 py-2.5 transition hover:bg-accent/50">
<Checkbox id={id} checked={value.includes(s.id)} onCheckedChange={(v) => toggle(s.id, v === true)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{s.name}</span>
<span className="block truncate font-mono text-xs text-muted-foreground">{sshAddress(s)}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<SshStatusDot server={s} />
<span className="hidden @xl:inline">{SSH_STATUS_LABEL[sshStatus(s)]}</span>
</span>
</label>
);
})}
</div>
<p className="text-xs text-muted-foreground">Every run of this agent can sign in to these. A single chat can add more with the SSH button in its message box.</p>
</div>
);
}

function WorkspaceField({ value, onChange, disabled }: { value: string | null; onChange: (v: string | null) => void; disabled?: boolean }) {
const { data: workspaces = [] } = useWorkspaces();
return (
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/components/chat/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
Camera,
Code2,
Eye,
FileDown,
FilePen,
FilePlus2,
FileSearch,
FileText,
FileUp,
FolderSearch,
Globe,
History,
Expand All @@ -35,6 +37,7 @@ import {
ScanText,
ScrollText,
Search,
Server,
ShieldAlert,
ShieldCheck,
Terminal,
Expand Down Expand Up @@ -227,6 +230,46 @@ function vmMeta(tool: string, input: Input): Omit<ToolMeta, "server" | "tool"> {
}
}

/** "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<ToolMeta, "server" | "tool"> {
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<ToolMeta, "kind" | "server" | "tool"> {
const app = str(input.app_name ?? input.app ?? input.name ?? input.bundle_id);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PanelLeft,
Plug,
Search,
Server,
Settings,
ShieldCheck,
SquareKanban,
Expand Down Expand Up @@ -116,6 +117,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{ to: "/browser", label: "Browser", icon: <Globe /> },
{ to: "/computer", label: "Computer", icon: <MonitorUp /> },
{ to: "/vms", label: "Virtual machines", icon: <Box /> },
{ to: "/ssh", label: "SSH servers", icon: <Server /> },
];

return (
Expand Down
Loading
Loading