From 3f5779da2c5b7eb35cc0060261e2545a6d070795 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Fri, 14 Aug 2026 08:32:03 +0530 Subject: [PATCH] Settings as a modal with sections, and a local computer you can set up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App settings were one long right-hand panel. They are now a modal with a section list — General, Connections, Local computer — which is where new settings can go without making one column longer. The new section sets up a computer your bots can borrow: a Linux desktop in a container on this machine, free and disposable, separate from your own desktop and files. It runs the same X11 desktop our computer tools already speak, so nothing about the tools changes — only where the commands run. It is a checklist that watches itself rather than a wall of instructions. A read-only status endpoint reports which container runtime is installed, whether its daemon is actually up, whether the image is pulled and whether the container is running; the panel polls while you work through the steps and ticks them off. Installed-but-not-running is called out separately, because that one otherwise fails later and reads as broken — verified against a real Docker install in exactly that state. We never tell anyone to install Docker specifically: its licence needs payment above 250 employees or $10M revenue and for government use, so the steps suggest Colima or Podman and we adapt to whatever is present. Nothing here installs anything; it only reports and shows commands. Co-Authored-By: Claude Fable 5 --- server/container-computer.ts | 110 +++++++++++++ server/index.ts | 8 + src/App.tsx | 4 +- src/components/LocalComputerSection.tsx | 175 ++++++++++++++++++++ src/components/SettingsModal.tsx | 205 ++++++++++++++++++++++++ 5 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 server/container-computer.ts create mode 100644 src/components/LocalComputerSection.tsx create mode 100644 src/components/SettingsModal.tsx 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 };