diff --git a/server/container-computer.ts b/server/container-computer.ts new file mode 100644 index 000000000..5e0d5213c --- /dev/null +++ b/server/container-computer.ts @@ -0,0 +1,110 @@ +// A computer your bots can use, running on this machine in a container. +// +// The cloud box needs a paid plan, and the "This Mac" path points an agent +// at the desktop the human is using. A container sits between them: free, +// disposable, and isolated from the user's files — and it runs the same +// X11 desktop our computer tools already speak (xdotool + scrot), so the +// tool layer doesn't change, only where the commands run. +// +// This module is deliberately read-only. It reports what is installed and +// never installs anything on someone's machine. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { augmentedPath } from "./env-path.ts"; + +const run = promisify(execFile); + +/** The desktop image. Anthropic's computer-use demo image is the one whose + * toolchain matches our shell exactly — it ships xdotool, scrot and + * imagemagick — and it is MIT, with an arm64 build as well as x86. */ +export const IMAGE = "ghcr.io/anthropics/anthropic-quickstarts:computer-use-demo-latest"; +export const CONTAINER = "openmausbot-computer"; + +/** Runtimes we can drive, best-known first. Docker is listed because most + * people already have it, NOT because we recommend installing it: its + * licence requires payment above 250 employees or $10M revenue, and for + * every government user. We adapt to whatever is already there. */ +const RUNTIMES = ["docker", "podman", "container"] as const; +export type Runtime = (typeof RUNTIMES)[number]; + +async function sh(cmd: string, args: string[], timeout = 8000) { + return run(cmd, args, { timeout, env: { ...process.env, PATH: augmentedPath() } }); +} + +async function installed(cmd: string): Promise { + try { + await sh(process.platform === "win32" ? "where.exe" : "/usr/bin/which", [cmd], 4000); + return true; + } catch { + return false; + } +} + +export interface ContainerComputerStatus { + /** the runtime we will use, or null when none is installed */ + runtime: Runtime | null; + /** everything we found, so the UI can say which one it picked */ + available: Runtime[]; + /** installed is not the same as running */ + daemonUp: boolean; + image: boolean; + container: "running" | "stopped" | "missing"; + image_ref: string; + container_name: string; +} + +export async function containerComputerStatus(): Promise { + const available: Runtime[] = []; + for (const r of RUNTIMES) if (await installed(r)) available.push(r); + const runtime = available[0] ?? null; + const status: ContainerComputerStatus = { + runtime, + available, + daemonUp: false, + image: false, + container: "missing", + image_ref: IMAGE, + container_name: CONTAINER, + }; + if (!runtime) return status; + + // Docker Desktop can be installed with its VM stopped, which fails every + // later command in a way that reads as "broken" rather than "not started" + try { + await sh(runtime, ["info", "--format", "{{.ServerVersion}}"], 10_000); + status.daemonUp = true; + } catch { + return status; + } + + try { + await sh(runtime, ["image", "inspect", IMAGE]); + status.image = true; + } catch { + /* not pulled yet */ + } + + try { + const { stdout } = await sh(runtime, ["inspect", "-f", "{{.State.Running}}", CONTAINER]); + status.container = stdout.trim() === "true" ? "running" : "stopped"; + } catch { + /* no such container */ + } + return status; +} + +/** The commands the user is shown, built from the same constants the check + * above uses — so the instructions can't drift from what we look for. */ +export function setupCommands(runtime: Runtime | null) { + const rt = runtime ?? "docker"; + return { + pull: `${rt} pull ${IMAGE}`, + // 6080 is the desktop in a browser, 5900 for a native VNC viewer + run: `${rt} run -d --name ${CONTAINER} -p 6080:6080 -p 5900:5900 ${IMAGE}`, + start: `${rt} start ${CONTAINER}`, + stop: `${rt} stop ${CONTAINER}`, + remove: `${rt} rm -f ${CONTAINER}`, + view: "http://localhost:6080/vnc.html", + }; +} diff --git a/server/index.ts b/server/index.ts index 6bacdd15c..6d557ba94 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { approvalKey, autoDecision } from "./auto-approve.ts"; import * as box from "./box.ts"; import * as composio from "./composio.ts"; +import { containerComputerStatus, setupCommands } from "./container-computer.ts"; import { ensureDirs, instanceConfigs, loadConfig, saveConfig, EVENTS_DIR, NATIVE_DIR } from "./config.ts"; import type { RuntimeEvent } from "./contracts.ts"; @@ -1213,6 +1214,13 @@ const server = createServer(async (req, res) => { return json(res, 200, { bot: fresh }); } + // what the user's machine can host: which runtime is installed, whether + // its daemon is up, and whether the desktop image and container exist + if (method === "GET" && path === "/api/local-computer") { + const status = await containerComputerStatus(); + return json(res, 200, { ...status, commands: setupCommands(status.runtime) }); + } + // identity handshake for the packaged app's port fallback: the forked // child proves it is OURS by echoing its pid (a stray dev server has // the same API shape but a different pid) diff --git a/src/App.tsx b/src/App.tsx index 52801363a..13e8997b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import { GroupView } from "@/components/GroupView"; import { SettingsPanel } from "@/components/SettingsPanel"; import { PluginsPanel } from "@/components/PluginsPanel"; import { ComputerPanel } from "@/components/ComputerPanel"; -import { AppSettingsPanel } from "@/components/AppSettingsPanel"; +import { SettingsModal } from "@/components/SettingsModal"; import { UpdateBanner } from "@/components/UpdateBanner"; import { DesktopCapabilitiesProvider } from "@/components/DesktopCapabilities"; @@ -72,7 +72,7 @@ function Shell() { )} {state.settingsOpen && bot && } {state.computerOpen && bot && } - {state.appSettingsOpen && } + {state.appSettingsOpen && } {state.pluginsOpen && } diff --git a/src/components/LocalComputerSection.tsx b/src/components/LocalComputerSection.tsx new file mode 100644 index 000000000..bbe79d6d5 --- /dev/null +++ b/src/components/LocalComputerSection.tsx @@ -0,0 +1,175 @@ +// Setting up a computer your bots can use, on your own machine. +// +// Not a wall of instructions: it looks at what you actually have and only +// tells you the next thing to do. Each step reports its own state, so a +// half-finished setup is obvious instead of mysterious. +import { useCallback, useEffect, useState } from "react"; +import { AlertTriangle, Check, Circle, ExternalLink, Loader2, RefreshCw } from "lucide-react"; +import { Card, CommandLine } from "./SettingsModal"; +import { cn } from "@/lib/cn"; + +interface Status { + runtime: string | null; + available: string[]; + daemonUp: boolean; + image: boolean; + container: "running" | "stopped" | "missing"; + image_ref: string; + container_name: string; + commands: Record; +} + +function Step({ + n, + title, + done, + children, +}: { + n: number; + title: string; + done: boolean; + children?: React.ReactNode; +}) { + return ( +
+
+ {done ? : n} +
+
+
{title}
+ {!done && children &&
{children}
} +
+
+ ); +} + +export function LocalComputerSection() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(() => { + setLoading(true); + fetch("/api/local-computer") + .then((r) => r.json()) + .then(setStatus) + .catch(() => setStatus(null)) + .finally(() => setLoading(false)); + }, []); + + useEffect(refresh, [refresh]); + // while the user is following the steps in a terminal, keep up with them + useEffect(() => { + const t = setInterval(refresh, 5000); + return () => clearInterval(t); + }, [refresh]); + + const c = status?.commands ?? {}; + const ready = status?.container === "running"; + + return ( + <> + +
+ + {loading ? ( + + ) : ready ? ( + + ) : ( + + )} + {loading ? "Checking…" : ready ? "Ready" : "Not set up yet"} + + + {ready && ( + + Watch the screen + + )} +
+
+ + +
+ +
+ Any of these works — we use whichever you already have. Colima and{" "} + Podman are free for everyone; Docker Desktop needs a paid licence for + companies over 250 people or $10M revenue, and for government use. +
+ +
+ + + + + + + + + + + + +
+
+ + {status && !status.runtime && ( + +
+ + + No container runtime found on this machine yet. Step 1 installs one; everything after it is + automatic. + +
+
+ )} + + +
+ + +
+
+ + ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx new file mode 100644 index 000000000..0006f91f4 --- /dev/null +++ b/src/components/SettingsModal.tsx @@ -0,0 +1,205 @@ +// App settings, as a real modal with sections rather than one long panel. +// Per-bot settings (persona, model, computer) stay in SettingsPanel — this +// is the stuff shared by every bot: who you are, your keys, and the +// machine your bots can borrow. +import { useEffect, useState } from "react"; +import { Check, Copy, KeyRound, Monitor, RefreshCw, User, X } from "lucide-react"; +import { useStore } from "@/state/store"; +import { ApiKeyRow } from "./ApiKeys"; +import { useUpdaterState } from "@/lib/updater"; +import { LocalComputerSection } from "./LocalComputerSection"; +import { cn } from "@/lib/cn"; + +type SectionId = "general" | "connections" | "computer"; + +const SECTIONS: Array<{ id: SectionId; label: string; icon: typeof User }> = [ + { id: "general", label: "General", icon: User }, + { id: "connections", label: "Connections", icon: KeyRound }, + { id: "computer", label: "Local computer", icon: Monitor }, +]; + +/** Name + email, persisted to /api/config {profile} on blur. */ +function ProfileFields() { + const { state, dispatch } = useStore(); + const [name, setName] = useState(state.config?.profile?.name ?? ""); + const [email, setEmail] = useState(state.config?.profile?.email ?? ""); + useEffect(() => { + setName(state.config?.profile?.name ?? ""); + setEmail(state.config?.profile?.email ?? ""); + }, [state.config?.profile?.name, state.config?.profile?.email]); + + const save = () => { + void fetch("/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: { name: name.trim(), email: email.trim().toLowerCase() } }), + }) + .then((r) => r.json()) + .then((config) => dispatch({ type: "configStatus", config })) + .catch(() => {}); + }; + + const inputClass = + "w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[14px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none"; + return ( +
+ setName(e.target.value)} onBlur={save} placeholder="Your name" className={inputClass} /> + setEmail(e.target.value)} + onBlur={save} + placeholder="you@example.com" + className={inputClass} + /> +
+ ); +} + +function UpdatesRow() { + const s = useUpdaterState(); + if (!window.ogb?.updater) return null; + const updater = window.ogb.updater; + const label = + s?.status === "checking" + ? "Checking…" + : s?.status === "available" + ? `${s.version} available` + : s?.status === "downloading" + ? `Downloading ${Math.round(s.percent ?? 0)}%` + : s?.status === "downloaded" + ? `${s.version} ready — restart to apply` + : "You're on the latest version we know of."; + return ( + + + + ); +} + +export function Card({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children?: React.ReactNode; +}) { + return ( +
+
{title}
+ {subtitle &&
{subtitle}
} + {children &&
{children}
} +
+ ); +} + +/** A command the user is meant to run, with one-click copy. */ +export function CommandLine({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + return ( +
+ + {command} + + +
+ ); +} + +export function SettingsModal() { + const { dispatch } = useStore(); + const [section, setSection] = useState("general"); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && dispatch({ type: "toggleAppSettings", open: false }); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [dispatch]); + + return ( +
e.target === e.currentTarget && dispatch({ type: "toggleAppSettings", open: false })} + > +
+ {/* section nav */} + + +
+
+ + {SECTIONS.find((s) => s.id === section)?.label} + + +
+ +
+ {section === "general" && ( + <> + + + + + + )} + + {section === "connections" && ( + +
+ + + +
+
+ )} + + {section === "computer" && } +
+
+
+
+ ); +} + +export { RefreshCw };