diff --git a/apps/desktop/src/components/runners/runner-card.tsx b/apps/desktop/src/components/runners/runner-card.tsx index d726950..6dd3075 100644 --- a/apps/desktop/src/components/runners/runner-card.tsx +++ b/apps/desktop/src/components/runners/runner-card.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; -import { Cookie, Ellipsis, HeartPulse, KeyRound, Monitor, MonitorSmartphone, Network, Pencil, RefreshCw, Sparkles, Trash2, TriangleAlert } from "lucide-react"; +import { CircleArrowUp, CircleCheck, Cookie, Ellipsis, HeartPulse, KeyRound, Monitor, MonitorSmartphone, Network, Pencil, RefreshCw, Sparkles, Trash2, TriangleAlert } from "lucide-react"; import type { RemoteRunner } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { @@ -31,6 +31,7 @@ import { runnerVerdict, runnerWorkLine, } from "./runner-parts"; +import { RunnerUpdateStrip, hasUpdateNews, shortBuild } from "./runner-update"; import type { RunnerActions } from "./use-runner-actions"; const FOOTER_BUTTON = "h-7 gap-1.5 px-2 text-[12.5px] font-normal text-muted-foreground hover:text-foreground [&_svg]:size-3.5"; @@ -112,7 +113,15 @@ export function RunnerCard({ · - Godmode {runner.version} + + Godmode {runner.version} + {shortBuild(runner.build) && {shortBuild(runner.build)}} + + {online && runner.update.state === "current" && !runner.update.tools.length && ( + + Up to date + + )} )}

@@ -178,6 +187,8 @@ export function RunnerCard({ )} + {hasUpdateNews(runner.update) && } +

@@ -311,6 +322,25 @@ function RunnerMenu({ runner, actions, onRename, onAddresses, onRemove }: { runn + { + e.preventDefault(); + actions.update.mutate({ runner, patch: { autoUpdate: !runner.update.autoUpdate } }); + }} + > + + + Update automatically + Gets this computer's Godmode once its chats are done. + + + + actions.upgrade.mutate(runner)}> + {runner.update.state === "available" || runner.update.tools.length ? "Update now" : "Check for updates"} + Remove… diff --git a/apps/desktop/src/components/runners/runner-parts.tsx b/apps/desktop/src/components/runners/runner-parts.tsx index 1c66528..465af84 100644 --- a/apps/desktop/src/components/runners/runner-parts.tsx +++ b/apps/desktop/src/components/runners/runner-parts.tsx @@ -219,6 +219,7 @@ export function HealthPill({ /** One line that answers "can I give it work right now?". */ export function runnerVerdict(runner: RemoteRunner, summary: RunnerHealthSummary | null): { tone: CheckTone | "busy"; label: string } { + if (runner.update.state === "restarting") return { tone: "busy", label: "Restarting with the new Godmode…" }; switch (runner.state) { case "connecting": return { tone: "busy", label: "Looking for it on the network…" }; @@ -251,7 +252,7 @@ export interface RunnerProblem { * runner itself is the checks' business.) */ export function runnerProblem(runner: RemoteRunner): RunnerProblem | null { - if (runner.state === "offline") { + if (runner.state === "offline" && runner.update.state !== "restarting") { return { kind: "connection", tone: "fail", @@ -259,14 +260,8 @@ export function runnerProblem(runner: RemoteRunner): RunnerProblem | null { detail: `${sentence(runner.error ?? "Is it awake and on the same network?")} Godmode keeps trying — its chats continue when it's back.`, }; } - if (runner.state === "update_required") { - return { - kind: "connection", - tone: "warn", - title: `${runner.name} runs another version of Godmode`, - detail: `${runner.error ? `${sentence(runner.error)} ` : ""}Update Godmode on both computers to the same version, then try again.`, - }; - } + // The update strip says what to do (and has the command). + if (runner.state === "update_required") return null; if (runner.state !== "online") return null; if (runner.sync.state === "failed") { return { diff --git a/apps/desktop/src/components/runners/runner-update.tsx b/apps/desktop/src/components/runners/runner-update.tsx new file mode 100644 index 0000000..1c7b20d --- /dev/null +++ b/apps/desktop/src/components/runners/runner-update.tsx @@ -0,0 +1,117 @@ +import { ArrowUpRight, CircleArrowUp, CircleX, Hourglass, RefreshCw, RotateCw, TriangleAlert } from "lucide-react"; +import type { RemoteRunner, RunnerUpdate } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { CommandBlock, THIS_COMPUTER_INLINE } from "./runner-parts"; +import type { RunnerActions } from "./use-runner-actions"; + +/** "6e83efe 2026-10-07" → "6e83efe"; "dev" stays. */ +export function shortBuild(build: string | null | undefined): string | null { + const commit = build?.trim().split(/\s+/)[0]?.replace(/\+changes$/, "*"); + return commit && commit !== "unknown" ? commit : null; +} + +/** Whether the card shows the update strip: something to install, on its way, or in the way. */ +export function hasUpdateNews(update: RunnerUpdate): boolean { + return update.state !== "current" || update.tools.length > 0; +} + +const BUSY: ReadonlySet = new Set(["sending", "waiting", "installing", "restarting"]); + +function versionLine(runner: RemoteRunner): string { + const { target } = runner.update; + const from = [runner.version, shortBuild(runner.build)].filter(Boolean).join(" · "); + const to = [target.version, shortBuild(target.build)].filter(Boolean).join(" · "); + return from && from !== to ? `Godmode ${from} → ${to}` : `Godmode ${to}`; +} + +function toolsLine(update: RunnerUpdate): string | null { + if (!update.tools.length) return null; + return update.tools.map((t) => (t.current && t.latest ? `${t.name} ${t.current} → ${t.latest}` : t.name)).join(", "); +} + +function copy(runner: RemoteRunner): { title: string; detail: string | null } { + const u = runner.update; + const tools = toolsLine(u); + switch (u.state) { + case "available": + return { + title: "Update available", + detail: [`${versionLine(runner)}, the one ${THIS_COMPUTER_INLINE} runs`, tools].filter(Boolean).join(". Also: "), + }; + case "current": + return { title: u.tools.length === 1 ? "Tool update" : "Tool updates", detail: tools }; + case "sending": + return { + title: u.source === "bridge" ? "Fetching the new Godmode" : "Sending the new Godmode", + detail: u.detail ?? (u.source === "controller" ? `From ${THIS_COMPUTER_INLINE}, end-to-end encrypted` : null), + }; + case "waiting": + return { title: "Ready to install", detail: u.detail ?? "It installs once its chats are done." }; + case "installing": + return { title: "Installing", detail: u.detail ?? "Checking the new Godmode and putting it in place" }; + case "restarting": + return { title: "Restarting", detail: "With the new Godmode. It's back in a moment." }; + case "failed": + return { title: "The update didn't work", detail: u.detail }; + case "unsupported": + return { title: "Update it on the runner", detail: u.detail }; + } +} + +/** + * The runner's update, right in its card: what it would get, the progress while it gets there, and what to do when it + * can't be updated from here. + */ +export function RunnerUpdateStrip({ runner, actions, className }: { runner: RemoteRunner; actions: RunnerActions; className?: string }) { + const u = runner.update; + const busy = BUSY.has(u.state); + const starting = actions.isBusy("upgrade", runner.id); + const online = runner.state === "online"; + const { title, detail } = copy(runner); + const tone = u.state === "failed" ? "fail" : u.state === "unsupported" ? "warn" : "brand"; + const Icon = u.state === "failed" ? CircleX : u.state === "unsupported" ? TriangleAlert : u.state === "waiting" ? Hourglass : u.state === "restarting" ? RotateCw : CircleArrowUp; + const percent = u.progress === null ? null : Math.round(u.progress * 100); + const actionable = u.state === "available" || u.state === "failed" || (u.state === "current" && u.tools.length > 0); + + return ( +

+
+ + {busy && u.state !== "waiting" ? : } + +
+

+ {title} + {percent !== null && u.state === "sending" && {percent}%} +

+ {detail &&

{detail}

} +
+ {actionable && ( + + )} +
+ {u.command && } + {u.state === "sending" && ( +
+
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/runners/use-runner-actions.ts b/apps/desktop/src/components/runners/use-runner-actions.ts index 2078478..095e85c 100644 --- a/apps/desktop/src/components/runners/use-runner-actions.ts +++ b/apps/desktop/src/components/runners/use-runner-actions.ts @@ -8,7 +8,7 @@ import { api } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; import { upsertRunner } from "@/lib/realtime"; -type RunnerAction = "connect" | "sync" | "health" | "autofix" | "update" | "remove"; +type RunnerAction = "connect" | "sync" | "health" | "autofix" | "update" | "upgrade" | "remove"; /** * Reconnect, copy the setup, check health, fix with Claude, change and remove runners — with toasts, and the answer @@ -83,16 +83,38 @@ export function useRunnerActions() { onSettled: (_res, _e, { runner }) => mark("autofix", runner.id, false), }); + /** Its Godmode to this computer's, its tools to their newest. The card follows the progress (runner.updated). */ + const upgrade = useMutation({ + mutationFn: (runner: RemoteRunner) => api.runners.upgrade(runner.id), + onMutate: (runner) => mark("upgrade", runner.id, true), + onSuccess: (next) => { + void upsertRunner(qc, next); + const u = next.update; + if (u.state === "failed") toast.error(`Couldn't update ${next.name}`, { description: u.detail ?? undefined }); + else if (u.state === "waiting") toast.success(`${next.name} installs the update next`, { description: u.detail ?? undefined }); + else if (u.state === "current" && !u.tools.length) toast.success(`${next.name} is up to date`, { description: `Godmode ${u.target.version} and all of its tools.` }); + }, + onError: (e, runner) => toastApiError(e, `Couldn't update ${runner.name}`, qc), + onSettled: (_res, _e, runner) => mark("upgrade", runner.id, false), + }); + const update = useMutation({ mutationFn: ({ runner, patch }: { runner: RemoteRunner; patch: RunnerPatch }) => api.runners.update(runner.id, patch), onMutate: ({ runner, patch }) => { mark("update", runner.id, true); // The switch in the menu answers at once. if (patch.syncBrowser !== undefined) void upsertRunner(qc, { ...runner, syncBrowser: patch.syncBrowser }); + if (patch.autoUpdate !== undefined) void upsertRunner(qc, { ...runner, update: { ...runner.update, autoUpdate: patch.autoUpdate } }); }, onSuccess: (next, { patch }) => { void upsertRunner(qc, next); if (patch.name !== undefined) toast.success(`Renamed to ${next.name}`); + else if (patch.autoUpdate !== undefined) + toast.success(next.update.autoUpdate ? "Updates install by themselves" : "Updates wait for you", { + description: next.update.autoUpdate + ? `${next.name} gets this computer's Godmode whenever it runs another one, once its chats are done.` + : `${next.name} keeps its Godmode until you click Update.`, + }); else if (patch.syncBrowser !== undefined) toast.success(next.syncBrowser ? "Browser sessions are copied along" : "Browser sessions stay on this computer", { description: next.syncBrowser @@ -121,7 +143,7 @@ export function useRunnerActions() { onSettled: (_res, _e, runner) => mark("remove", runner.id, false), }); - return { connect, sync, checkHealth, autofix, update, remove, isBusy }; + return { connect, sync, checkHealth, autofix, upgrade, update, remove, isBusy }; } export type RunnerActions = ReturnType; diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 1197ee3..fd09e07 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -187,6 +187,7 @@ --animate-dream-dot: dream-dot 2s ease-out infinite; --animate-march: march 0.6s linear infinite; --animate-ekg: ekg 2.8s cubic-bezier(0.45, 0, 0.3, 1) infinite; + --animate-indeterminate: indeterminate 1.4s ease-in-out infinite; @keyframes shimmer { 0% { background-position: 200% 0; } @@ -226,6 +227,10 @@ 0% { stroke-dashoffset: 28; } 70%, 100% { stroke-dashoffset: -100; } } + @keyframes indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(300%); } + } } @layer base { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 4c46b65..21a905c 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -114,6 +114,7 @@ import type { RunnerHealth, RunnerPairingOffer, RunnerPatch, + RunnerUpdateInput, SendMessageInput, SendMessageOutcome, SendMessageResult, @@ -676,6 +677,8 @@ export const api = { /** `refresh` runs the checks on the runner again instead of answering from its last result. */ health: (id: string, refresh = false) => get(`/api/runners/${id}/health`, { refresh: refresh ? 1 : undefined }), fix: (id: string, checkId: string) => post(`/api/runners/${id}/health/fix`, { id: checkId }), + /** Its Godmode to this computer's and its tools to their newest; the progress follows as `runner.updated`. */ + upgrade: (id: string, input: RunnerUpdateInput = {}) => post(`/api/runners/${id}/update`, input), /** Starts a chat here whose agent diagnoses and repairs the runner. */ autofix: (id: string, input: RunnerAutofixInput = {}) => post(`/api/runners/${id}/autofix`, input), /** Any API call answered by the runner instead of this computer: `path` is the route without `/api`, e.g. "/computer/sources". */ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86e4da8..e7ca990 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1474,6 +1474,21 @@ A **runner** is a headless Godmode core on another computer (macOS for now) that `godmode runner status` asks the serving runner (`/api/runner/health` on loopback) for its own view. **Fix with Claude** starts a chat on this computer with `conversations.runner_tools_id`: its agent gets `runner_health`, `runner_fix` and `runner_exec` (a login shell on the runner, in its data dir) and the health report and log tail. +* **Updates** (`runnerUpdates.ts` on the controller, `selfUpdate.ts` on the runner). A runner gets the Godmode this + computer runs: the same program when both are the same platform and architecture (compared by SHA-256, `RunnerInfo.digest`; + sent over the link in 4 MiB pieces, `PUT /api/link/update/chunk`), else the release of the same version from + usegodmode.com with this computer's licence key (`/api/link/update/download`, compared by version). Never an older + one. The runner stages it next to its executable (`.godmode-update`), checks size and SHA-256, starts it once with + `version`, waits until no run works, renames it over the executable and restarts: under launchd it exits with 75 so + KeepAlive starts the new program, otherwise a shell waits for the old process and starts the new one with the same + arguments. Meta `update.pending` lets the restarted runner report a build that didn't take. Runners from before the + updater (no `build` in RunnerInfo) fetch this computer's program once from a short-lived token URL through + `/api/link/exec` (the bridge, macOS service only). Each runner has `auto_update` (default on): it is updated when it + connects, every 15 minutes while connected and when the switch goes on; a failed automatic attempt isn't repeated for + the same build for 6 hours. `POST /api/runners/:id/update` (the Update button) does the same at once and then installs + the runner's tool updates (`/api/link/updates`, `/api/link/updates/install`; runners also keep their tools current + through their own background upkeep). What it can't do from here (a runner from source, another link protocol, no + licence key) comes back as `unsupported` with a command to run there. * **Removing a runner** tells it to forget this computer, fails its working runs and turns its chats into chats of this computer. Backups carry no runners, controllers or `link.*` meta, and restored chats lose their runner. diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index badc9d4..91bd033 100644 --- a/packages/core/src/cloud/scope.ts +++ b/packages/core/src/cloud/scope.ts @@ -227,8 +227,8 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["PATCH|DELETE", "/api/goals/:id", A], ["GET", "/api/tasks/:id/events", A], // Questions agents ask before they act: reading and answering them is ordinary use of the dashboard. - // Runners: seeing them is fine from anywhere. Pairing, copying the setup (logins, 2FA, sessions) to them, fixing - // and removing them, and the runner's own link API (/api/link/*, which includes running commands) stay on the + // Runners: seeing them is fine from anywhere. Pairing, copying the setup (logins, 2FA, sessions) to them, fixing, + // updating and removing them, and the runner's own link API (/api/link/*, which includes running commands) stay on the // computer. The proxy to a runner (/api/runners/:id/proxy/…) matches no entry, so it is refused as well. ["GET", "/api/runners", A], ["GET", "/api/runners/:id", A], @@ -240,6 +240,7 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["POST", "/api/runners/:id/sync", refused(COMPUTER_ONLY)], ["POST", "/api/runners/:id/health/fix", refused(COMPUTER_ONLY)], ["POST", "/api/runners/:id/autofix", refused(COMPUTER_ONLY)], + ["POST", "/api/runners/:id/update", refused(COMPUTER_ONLY)], ["GET", "/api/link/info", refused(COMPUTER_ONLY)], ["POST", "/api/link/sync", refused(COMPUTER_ONLY)], ["GET", "/api/link/health", refused(COMPUTER_ONLY)], @@ -248,6 +249,11 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["POST", "/api/link/browser/:profileId/cookies", refused(COMPUTER_ONLY)], ["POST", "/api/link/exec", refused(COMPUTER_ONLY)], ["POST", "/api/link/forget", refused(COMPUTER_ONLY)], + ["PUT", "/api/link/update/chunk", refused(COMPUTER_ONLY)], + ["POST", "/api/link/update/apply", refused(COMPUTER_ONLY)], + ["POST", "/api/link/update/download", refused(COMPUTER_ONLY)], + ["GET", "/api/link/updates", refused(COMPUTER_ONLY)], + ["POST", "/api/link/updates/install", refused(COMPUTER_ONLY)], ["GET", "/api/questions", A], ["GET", "/api/questions/:id", A], ["POST", "/api/questions/:id/answer", A], diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 576ecb7..e929a17 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -1192,6 +1192,14 @@ CREATE INDEX IF NOT EXISTS idx_watchdog_events_agent ON watchdog_events(agent_id sql: /* sql */ ` -- 1: a delivered ticket's pull request is merged into its base branch right away (no review step). ALTER TABLE workspaces ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0; +`, + }, + { + id: 76, + name: "runner_auto_update", + sql: /* sql */ ` +-- 1: the runner gets this computer's Godmode by itself when it runs another one (remote/runnerUpdates.ts). +ALTER TABLE runners ADD COLUMN auto_update INTEGER NOT NULL DEFAULT 1; `, }, ]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3bb7f81..70bebd9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,7 @@ import { getModelCatalog } from "./runner/models"; import { refreshMobileAccess, startMobileAccess, stopMobileAccess } from "./mobile/access"; import { answerHealth, HEALTH_PATH, runnerFile, runningRunner, runRunnerCli, servingRunner, USAGE as RUNNER_USAGE, type RunnerProcess } from "./remote/cli"; import { startLinkServer, stopLinkServer } from "./remote/linkServer"; +import { RESTART_EXIT_CODE, executableDigest, setRestartHandler, settleUpdate } from "./remote/selfUpdate"; import { startRunners, stopRunners } from "./remote/runners"; import { bootstrapDependencies } from "./remote/health"; import { startKeepAwake, stopKeepAwake } from "./remote/keepAwake"; @@ -248,6 +249,8 @@ async function serve(values: Record, role?: CoreConfig["role"]) if (runner) { // The way in for the computers it works for: encrypted, on every interface, at the port they were paired with. if (typeof values["link-port"] === "number") setMeta("link.port", String(values["link-port"])); + settleUpdate(); + void executableDigest()?.catch((err) => log.warn("could not hash this program", err)); const linkPort = startLinkServer({ app, websocket: websocketHandler }); // Tells `godmode runner install` and `status` that this runner is up; gone again when it stops. const info: RunnerProcess = { @@ -293,7 +296,7 @@ async function serve(values: Record, role?: CoreConfig["role"]) if (runner && process.env.GODMODE_RUNNER_BOOTSTRAP !== "0") void bootstrapDependencies(); let stopping = false; - const shutdown = async (signal: string) => { + const shutdown = async (signal: string, exitCode = 0) => { if (stopping) return; stopping = true; log.info(`received ${signal}, shutting down`, resourceSnapshot()); @@ -331,8 +334,10 @@ async function serve(values: Record, role?: CoreConfig["role"]) closeAllConnections(); server.stop(true); closeDb(); - process.exit(0); + process.exit(exitCode); }; + // A runner that installed a new Godmode stops like this, and comes back as the new program (remote/selfUpdate.ts). + if (runner) setRestartHandler(() => void shutdown("update", RESTART_EXIT_CODE)); process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGTERM", () => void shutdown("SIGTERM")); // Desktop shell closes our stdin when it exits — treat as shutdown signal. diff --git a/packages/core/src/license/license.ts b/packages/core/src/license/license.ts index 4e3d372..642dad9 100644 --- a/packages/core/src/license/license.ts +++ b/packages/core/src/license/license.ts @@ -162,6 +162,11 @@ function staleSince(v: Verdict, now: number): number | null { return Math.max(checked + LICENSE_STALE_MS, end); } +/** The stored key, for what needs it besides the licence check (a runner downloading Godmode from the site). */ +export function licenseKey(): string | null { + return getMeta(META.key); +} + export function licenseState(): LicenseState { const now = clock(); const key = getMeta(META.key); diff --git a/packages/core/src/remote/linkServer.ts b/packages/core/src/remote/linkServer.ts index 885e94d..feca2b5 100644 --- a/packages/core/src/remote/linkServer.ts +++ b/packages/core/src/remote/linkServer.ts @@ -13,7 +13,7 @@ import { hostname as osHostname, platform, arch } from "node:os"; import type { Server, ServerWebSocket, WebSocketHandler } from "bun"; import type { Hono } from "hono"; import { LINK_PROTOCOL, RUNNER_DEFAULT_PORT, type ClientEvent, type RunnerInfo } from "@godmode/shared"; -import { VERSION } from "../config"; +import { BUILD, COMPILED, VERSION } from "../config"; import { all, get, getMeta, insert, run as sql, setMeta } from "../db"; import { logger } from "../log"; import { computerName } from "../mobile/devices"; @@ -27,6 +27,7 @@ import { LinkError, SecureChannel, type LinkPeer, type Transport } from "./chann import { canonicalKey, controllerLookupId } from "./crypto"; import { loadIdentity } from "./identity"; import { consumePairing, lookupPairing } from "./pairing"; +import { knownExecutableDigest, selfUpdateStatus } from "./selfUpdate"; import { appliedDigest } from "./snapshot"; const log = logger("link"); @@ -126,6 +127,10 @@ export function runnerInfo(): RunnerInfo { platform: platform(), arch: arch(), version: VERSION, + build: BUILD, + compiled: COMPILED, + digest: knownExecutableDigest(), + update: selfUpdateStatus(), protocol: LINK_PROTOCOL, vault: { initialized: v.initialized, unlocked: v.unlocked }, configDigest: appliedDigest(), diff --git a/packages/core/src/remote/runnerUpdates.ts b/packages/core/src/remote/runnerUpdates.ts new file mode 100644 index 0000000..efa7ec0 --- /dev/null +++ b/packages/core/src/remote/runnerUpdates.ts @@ -0,0 +1,465 @@ +/** + * Keeping runners on this computer's Godmode (the controller's side of remote/selfUpdate.ts), and their tools current. + * + * Which Godmode a runner gets is always this computer's: the same program when both are the same kind of computer + * (sent over the link in pieces, compared by SHA-256), else the same release from usegodmode.com (compared by version). + * Runners from before the updater know neither, so they fetch this computer's program once from a short-lived + * listener through `runner_exec` (the bridge); after that they update like any other. + * + * With auto-update on (per runner, the default) a runner that connects with another Godmode gets this one; it waits + * for its runs to finish before it restarts. A failed attempt isn't repeated by itself for the same build for a while. + * The Update button does the same at once and installs the runner's tool updates (Claude Code, uv, Chromium…) too. + */ +import type { RemoteRunner, RunnerInfo, RunnerUpdate, RunnerUpdateSource, RunnerUpdateState, ToolUpdateResult, ToolUpdateStatus, UpdateReport } from "@godmode/shared"; +import { BUILD, COMPILED, VERSION } from "../config"; +import { licenseBaseUrl, licenseKey } from "../license/license"; +import { logger } from "../log"; +import { tailscaleStatus } from "../mobile/tailscale"; +import { audit } from "../services/audit"; +import { compareVersions } from "../services/claudeUpdate"; +import { notify } from "../services/notifications"; +import { HttpError, randomToken } from "../util"; +import type { RemoteLink } from "./linkClient"; +import { offerAddresses } from "./pairing"; +import { executableDigest, knownExecutableDigest, releaseAsset, type UpdateTarget } from "./selfUpdate"; +import { SERVICE_LABEL } from "./launchd"; + +const log = logger("runner-updates"); + +const CHUNK_BYTES = 4 * 1024 * 1024; +const CHUNK_TIMEOUT_MS = 2 * 60_000; +const APPLY_TIMEOUT_MS = 3 * 60_000; +const TOOLS_TIMEOUT_MS = 30 * 60_000; +const BRIDGE_TIMEOUT_MS = 15 * 60_000; +const POLL_MS = 10_000; +/** A runner that restarted and doesn't come back within this long: the update counts as failed. */ +const RESTART_TIMEOUT_MS = 5 * 60_000; +/** An automatic update that failed isn't tried again by itself for the same build for this long. */ +const RETRY_AUTO_MS = 6 * 60 * 60_000; + +/** What runners.ts lends: the runner's row, link, last info and a way to tell the UI. */ +export interface UpdateHost { + row(id: string): { id: string; name: string; platform: string | null; arch: string | null; version: string | null; auto_update: number } | null; + link(id: string): RemoteLink | null; + info(id: string): RunnerInfo | undefined; + setInfo(id: string, info: RunnerInfo): void; + activeRuns(id: string): number; + emit(id: string): void; +} + +interface Attempt { + state: Exclude; + source: RunnerUpdateSource | null; + progress: number | null; + detail: string | null; + /** Install the tool updates once the new Godmode is up. */ + tools: boolean; + at: number; + target: string; +} + +let host: UpdateHost | null = null; +const attempts = new Map(); +const toolReports = new Map(); +/** `${runner}:${target}` → when an automatic update of it failed. */ +const autoFailed = new Map(); +const polls = new Map>(); + +export function setUpdateHost(h: UpdateHost | null): void { + host = h; +} + +const target = (): UpdateTarget => ({ version: VERSION, build: BUILD }); + +/** Tests: this computer as a compiled program with this digest (null = as it really is). */ +let controllerOverride: { digest: string } | null = null; + +export function __setControllerForTests(value: { digest: string } | null): void { + controllerOverride = value; +} + +const compiled = () => !!controllerOverride || COMPILED; + +function ownDigest(): string | null { + if (controllerOverride) return controllerOverride.digest; + return COMPILED ? knownExecutableDigest() : null; +} + +/** Start hashing this program (the runners' digests are compared with it); resolves once known. */ +export async function prepareRunnerUpdates(): Promise { + if (COMPILED) await executableDigest()?.catch((err) => log.warn("couldn't hash this program", err)); +} + +const samePlatform = (r: { platform: string | null; arch: string | null }) => r.platform === process.platform && r.arch === process.arch; + +/** The command that installs this Godmode's release on the runner by hand. The key stays out: phones read the runner list too. */ +function manualCommand(): string { + return `curl -fsSL https://usegodmode.com/runner.sh | GODMODE_LICENSE=GM-XXXXX-XXXXX-XXXXX-XXXXX GODMODE_VERSION=v${VERSION} sh`; +} + +const ONCE_BY_HAND = "Its Godmode is older than the updater. Install the new one there once with this command, with your licence key from Settings → License."; + +interface Plan { + needed: boolean; + source: RunnerUpdateSource | null; + /** Why it can't be updated from here. */ + reason: string | null; + command: string | null; + /** Identifies what it would get, for remembering failed automatic attempts. */ + key: string; +} + +/** Does the runner need this computer's Godmode, and how would it get there? */ +export function planUpdate(r: { platform: string | null; arch: string | null; version: string | null }, info: RunnerInfo | undefined): Plan { + const mine = ownDigest(); + const key = mine ?? `${VERSION} ${BUILD}`; + const none = { needed: false, source: null, reason: null, command: null, key }; + const version = info?.version ?? r.version; + if (!version) return none; + // Never back to an older one: this computer needs the update then. + if (compareVersions(version, VERSION) > 0) return none; + const legacy = !!info && info.build === undefined; + const sameProgram = compiled() && samePlatform(r) && info?.compiled !== false; + let needed: boolean; + if (sameProgram && legacy) needed = true; + else if (sameProgram && mine && info?.digest) needed = info.digest !== mine; + else needed = compareVersions(version, VERSION) < 0; + if (!needed) return none; + + if (info?.compiled === false) return { needed, source: null, reason: "It runs Godmode from its sources. Update it there with git.", command: null, key }; + if (sameProgram && !legacy) return { needed, source: "controller", reason: null, command: null, key }; + if (sameProgram && legacy) { + return r.platform === "darwin" + ? { needed, source: "bridge", reason: null, command: null, key } + : { needed, source: null, reason: ONCE_BY_HAND, command: manualCommand(), key }; + } + if (legacy) return { needed, source: null, reason: ONCE_BY_HAND, command: manualCommand(), key }; + if (!releaseAsset(r.platform ?? "", r.arch ?? "")) return { needed, source: null, reason: `There is no Godmode download for ${r.platform ?? "its system"} ${r.arch ?? ""}.`.trim(), command: null, key }; + if (!licenseKey()) return { needed, source: null, reason: "Add your licence key (Settings → License): the runner downloads Godmode from usegodmode.com with it.", command: manualCommand(), key }; + return { needed, source: "website", reason: null, command: null, key }; +} + +function dueTools(id: string): RunnerUpdate["tools"] { + return (toolReports.get(id) ?? []).filter((t) => t.installed && t.updatable && t.updateAvailable).map(({ id, name, current, latest }) => ({ id, name, current, latest })); +} + +/** The update part of a runner as the UI sees it. */ +export function runnerUpdate(r: { id: string; platform: string | null; arch: string | null; version: string | null; auto_update: number }, linkState: RemoteRunner["state"]): RunnerUpdate { + const base = { target: target(), autoUpdate: r.auto_update === 1, tools: dueTools(r.id), command: null, source: null, progress: null, detail: null }; + if (linkState === "update_required") { + return { ...base, state: "unsupported", detail: "It runs a Godmode that can't talk to this one. Install the new one there with this command, with your licence key from Settings → License.", command: manualCommand() }; + } + const attempt = attempts.get(r.id); + const info = host?.info(r.id); + const plan = planUpdate(r, info); + if (attempt) return { ...base, state: attempt.state, source: attempt.source, progress: attempt.progress, detail: attempt.detail }; + const own = info?.update; + if (own?.state === "waiting") return { ...base, state: "waiting", source: plan.source, detail: waitingText(own.waitingFor) }; + if (own?.state === "installing") return { ...base, state: "installing", source: plan.source }; + if (!plan.needed) return { ...base, state: "current" }; + if (!plan.source) return { ...base, state: "unsupported", detail: plan.reason, command: plan.command }; + if (own?.state === "failed") return { ...base, state: "failed", source: plan.source, detail: own.error }; + return { ...base, state: "available", source: plan.source }; +} + +function waitingText(runs: number): string { + return runs > 0 ? `Installs once its ${runs === 1 ? "run is" : `${runs} runs are`} done.` : "Installs in a moment."; +} + +function set(id: string, attempt: Attempt | null) { + if (attempt) attempts.set(id, attempt); + else attempts.delete(id); + host?.emit(id); +} + +function patch(id: string, p: Partial) { + const a = attempts.get(id); + if (a) set(id, { ...a, ...p }); +} + +function message(err: unknown): string { + return err instanceof Error ? err.message.replace(/^Error:\s*/, "") : String(err); +} + +function failed(id: string, detail: string, auto: boolean) { + const a = attempts.get(id); + stopPoll(id); + set(id, { state: "failed", source: a?.source ?? null, progress: null, detail, tools: false, at: Date.now(), target: a?.target ?? "" }); + if (auto && a) autoFailed.set(`${id}:${a.target}`, Date.now()); + const name = host?.row(id)?.name ?? "The runner"; + log.warn(`${name}: update failed: ${detail}`); + if (auto) notify("warning", `${name} couldn't be updated`, detail, "/runners"); +} + +/* ------------------------------------------------------------------ */ +/* Tool reports */ +/* ------------------------------------------------------------------ */ + +/** Ask the runner which of its tools have updates (runners before the updater have no answer: none). */ +export async function refreshToolReport(id: string, refresh = false): Promise { + const l = host?.link(id); + if (!l || l.state.state !== "online") return; + try { + const report = await l.json("GET", `/api/link/updates${refresh ? "?refresh=1" : ""}`, undefined, { timeoutMs: 5 * 60_000 }); + toolReports.set(id, report.tools ?? []); + } catch (err) { + if (!(err instanceof HttpError && err.status === 404)) log.debug(`couldn't ask runner ${id} for its tool updates`, err); + toolReports.set(id, []); + } + host?.emit(id); +} + +async function installTools(id: string, l: RemoteLink): Promise { + const due = dueTools(id); + if (!due.length) return []; + set(id, { state: "installing", source: null, progress: null, detail: `Updating ${due.map((t) => t.name).join(", ")}`, tools: false, at: Date.now(), target: attempts.get(id)?.target ?? "" }); + const results = await l.json("POST", "/api/link/updates/install", undefined, { timeoutMs: TOOLS_TIMEOUT_MS }); + await refreshToolReport(id); + return results; +} + +/* ------------------------------------------------------------------ */ +/* Updating */ +/* ------------------------------------------------------------------ */ + +function stopPoll(id: string) { + const t = polls.get(id); + if (t) clearInterval(t); + polls.delete(id); +} + +/** While the runner waits for its runs, look at it now and then: it restarts by itself once they are done. */ +function poll(id: string) { + stopPoll(id); + const timer = setInterval(() => { + const a = attempts.get(id); + const l = host?.link(id); + if (!a) return stopPoll(id); + if (a.state === "restarting") { + if (Date.now() - a.at > RESTART_TIMEOUT_MS) failed(id, `${host?.row(id)?.name ?? "The runner"} didn't come back after installing the new Godmode. Check its screen or log.`, false); + return; + } + if (!l || l.state.state !== "online") return; + void l + .json("GET", "/api/link/info") + .then((info) => { + host?.setInfo(id, info); + const own = info.update; + if (own?.state === "failed") failed(id, own.error ?? "The update didn't work.", false); + else if (own?.state === "installing") patch(id, { state: "restarting", detail: null, at: Date.now() }); + else if (own?.state === "waiting") patch(id, { detail: waitingText(own.waitingFor) }); + }) + .catch(() => undefined); + }, POLL_MS); + timer.unref?.(); + polls.set(id, timer); +} + +/** Send this computer's program in pieces, with progress, and ask the runner to install it. */ +async function sendProgram(id: string, l: RemoteLink): Promise { + const sha256 = await executableDigest(); + if (!sha256) throw new Error("This computer runs Godmode from its sources and has no program to send."); + const file = Bun.file(process.execPath); + const size = file.size; + for (let offset = 0; offset < size; offset += CHUNK_BYTES) { + const bytes = new Uint8Array(await file.slice(offset, Math.min(offset + CHUNK_BYTES, size)).arrayBuffer()); + const res = await l.request("PUT", `/api/link/update/chunk?offset=${offset}&total=${size}`, { body: bytes, headers: { "content-type": "application/octet-stream" }, timeoutMs: CHUNK_TIMEOUT_MS }); + if (res.status < 200 || res.status >= 300) { + const answer = JSON.parse(Buffer.from(res.body).toString("utf8") || "{}") as { error?: string }; + throw new Error(answer.error ?? `The runner answered ${res.status}.`); + } + patch(id, { progress: Math.min((offset + bytes.byteLength) / size, 1) }); + } + patch(id, { state: "installing", progress: null, detail: "Checking and installing the new Godmode" }); + return l.json("POST", "/api/link/update/apply", { sha256, size, target: target() }, { timeoutMs: APPLY_TIMEOUT_MS }); +} + +/** Let the runner download this Godmode's release from usegodmode.com. */ +function downloadRelease(l: RemoteLink): Promise { + const site = licenseBaseUrl(); + return l.json( + "POST", + "/api/link/update/download", + { key: licenseKey(), target: { version: VERSION, build: "" }, ...(site !== "https://usegodmode.com" ? { site } : {}) }, + { timeoutMs: 20 * 60_000 }, + ); +} + +const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +/** The script a runner from before the updater runs (through runner_exec) to fetch this program and restart with it. */ +export function bridgeScript(urls: string[], sha256: string): string { + return [ + "set -e", + `plist="$HOME/Library/LaunchAgents/${SERVICE_LABEL}.plist"`, + `[ -f "$plist" ] || { echo "The runner isn't installed as a service here: run the install command on it once." >&2; exit 5; }`, + `bin=$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' "$plist")`, + 'tmp="$(dirname "$bin")/.godmode-update"', + "ok=", + `for u in ${urls.map(shellQuote).join(" ")}; do curl -fsS --connect-timeout 3 --max-time 600 "$u" -o "$tmp" && ok=1 && break; done`, + `[ -n "$ok" ] || { echo "The runner can't reach this computer to download the new Godmode." >&2; exit 3; }`, + `[ "$(shasum -a 256 "$tmp" | cut -d' ' -f1)" = "${sha256}" ] || { rm -f "$tmp"; echo "The download arrived damaged." >&2; exit 4; }`, + 'chmod 755 "$tmp"', + `"$tmp" version >/dev/null || { rm -f "$tmp"; echo "The new Godmode doesn't start there." >&2; exit 6; }`, + 'mv -f "$tmp" "$bin"', + `nohup /bin/sh -c 'sleep 2; launchctl kickstart -k gui/$(id -u)/${SERVICE_LABEL}' >/dev/null 2>&1 &`, + "echo installed", + ].join("\n"); +} + +/** Runners from before the updater: serve this program for a moment and let the runner fetch it with a shell command. */ +async function bridge(id: string, l: RemoteLink): Promise { + const sha256 = await executableDigest(); + if (!sha256) throw new Error("This computer runs Godmode from its sources and has no program to send."); + const token = randomToken(24); + const server = Bun.serve({ + hostname: "0.0.0.0", + port: 0, + idleTimeout: 255, + fetch: (req) => + req.method === "GET" && new URL(req.url).pathname === `/${token}/godmode` + ? new Response(Bun.file(process.execPath), { headers: { "content-type": "application/octet-stream", "cache-control": "no-store" } }) + : new Response("Not found", { status: 404 }), + }); + try { + const urls = offerAddresses(await tailscaleStatus(true)).map((a) => `http://${a.address}:${server.port}/${token}/godmode`); + if (!urls.length) throw new Error("This computer has no network address the runner could download from."); + const res = await l.json<{ code: number | null; stdout: string; stderr: string }>( + "POST", + "/api/link/exec", + { command: bridgeScript(urls, sha256), timeoutMs: BRIDGE_TIMEOUT_MS }, + { timeoutMs: BRIDGE_TIMEOUT_MS + 60_000 }, + ); + if (res.code !== 0) throw new Error(res.stderr.trim().split("\n").pop() || `The install stopped (exit ${res.code}).`); + } finally { + server.stop(true); + } +} + +/** + * Bring a runner to this computer's Godmode (when it runs another) and, with `tools`, its tools to their newest + * versions. Resolves once it is sent and installing, or waiting for its runs; the rest is reported as it happens. + */ +export async function updateRunnerSoftware(id: string, opts: { tools?: boolean; auto?: boolean } = {}): Promise { + const h = host; + if (!h) throw new HttpError(503, "Runners aren't started.", "runners_stopped"); + const r = h.row(id); + if (!r) throw new HttpError(404, "Runner not found", "not_found"); + const l = h.link(id); + if (!l || l.state.state !== "online") throw new HttpError(409, `${r.name} is offline — it can be updated once it is back.`, "runner_offline"); + const busy = attempts.get(id); + if (busy && busy.state !== "failed") throw new HttpError(409, `${r.name} is already being updated.`, "update_busy"); + const tools = opts.tools ?? true; + const auto = !!opts.auto; + const plan = planUpdate(r, h.info(id)); + + if (!plan.needed) { + if (!tools) return; + set(id, null); + try { + await refreshToolReport(id, true); + const results = await installTools(id, l); + set(id, null); + const bad = results.filter((x) => !x.ok); + if (bad.length) failed(id, `${bad.map((x) => x.name).join(", ")}: ${bad[0]!.output.split("\n").pop()}`, auto); + else audit("user", "runner.upgrade.tools", id, { tools: results.map((x) => `${x.id} ${x.version ?? ""}`.trim()) }); + } catch (err) { + failed(id, message(err), auto); + } + return; + } + if (!plan.source) throw new HttpError(409, plan.reason ?? `${r.name} can't be updated from here.`, "update_unsupported"); + if (plan.source === "bridge" && h.activeRuns(id) > 0) { + throw new HttpError(409, `Chats are working on ${r.name}. Update it once they are done.`, "runner_busy"); + } + + set(id, { + state: plan.source === "website" ? "installing" : "sending", + source: plan.source, + progress: plan.source === "controller" ? 0 : null, + detail: plan.source === "website" ? "Downloading Godmode from usegodmode.com" : plan.source === "bridge" ? "The runner downloads the new Godmode from this computer" : null, + tools, + at: Date.now(), + target: plan.key, + }); + audit(auto ? "system" : "user", "runner.upgrade", id, { to: `${VERSION} ${BUILD}`, source: plan.source }); + log.info(`updating ${r.name} to ${VERSION} (${BUILD}) via ${plan.source}`); + try { + if (plan.source === "bridge") { + await bridge(id, l); + patch(id, { state: "restarting", progress: null, detail: null, at: Date.now() }); + } else { + const own = plan.source === "controller" ? await sendProgram(id, l) : await downloadRelease(l); + if (own?.state === "waiting") patch(id, { state: "waiting", progress: null, detail: waitingText(own.waitingFor) }); + else patch(id, { state: "restarting", progress: null, detail: null, at: Date.now() }); + } + poll(id); + } catch (err) { + failed(id, message(err), auto); + } +} + +/** The runner (re)connected: finish an update it restarted for, or start one by itself. */ +export async function runnerConnected(id: string): Promise { + const h = host; + if (!h) return; + const r = h.row(id); + if (!r) return; + const a = attempts.get(id); + const info = h.info(id); + const plan = planUpdate(r, info); + if (a && a.state !== "failed" && a.state !== "sending") { + if (!plan.needed) { + stopPoll(id); + set(id, null); + log.info(`${r.name} runs Godmode ${VERSION} (${BUILD}) now`); + if (a.tools) { + await refreshToolReport(id, true); + const l = h.link(id); + if (l) await installTools(id, l).catch((err) => log.warn(`${r.name}: tool updates failed`, message(err))); + set(id, null); + } + return; + } + if (info?.update?.state === "failed") failed(id, info.update.error ?? "The update didn't work.", false); + else if (info?.update?.state === "waiting") patch(id, { state: "waiting", detail: waitingText(info.update.waitingFor) }); + else if (a.state === "restarting") failed(id, `${r.name} came back with its old Godmode.`, false); + return; + } + if (a?.state === "sending") failed(id, "The connection broke off while the new Godmode was sent.", false); + else if (a?.state === "failed" && !plan.needed) set(id, null); + await refreshToolReport(id); + await maybeAutoUpdate(id); +} + +/** An automatic update when the runner wants one, may have one and none failed lately for this build. */ +export async function maybeAutoUpdate(id: string): Promise { + const h = host; + const r = h?.row(id); + if (!h || !r || r.auto_update !== 1) return; + const l = h.link(id); + if (!l || l.state.state !== "online") return; + const a = attempts.get(id); + if (a && a.state !== "failed") return; + const plan = planUpdate(r, h.info(id)); + if (!plan.needed || !plan.source) return; + if (plan.source === "bridge" && h.activeRuns(id) > 0) return; + const lastFail = autoFailed.get(`${id}:${plan.key}`); + if (lastFail && Date.now() - lastFail < RETRY_AUTO_MS) return; + await updateRunnerSoftware(id, { auto: true, tools: false }).catch((err) => log.warn(`automatic update of ${r.name} didn't start`, message(err))); +} + +export function forgetRunnerUpdates(id: string): void { + stopPoll(id); + attempts.delete(id); + toolReports.delete(id); + for (const key of autoFailed.keys()) if (key.startsWith(`${id}:`)) autoFailed.delete(key); +} + +/** Tests and shutdown. */ +export function resetRunnerUpdates(): void { + for (const id of [...polls.keys()]) stopPoll(id); + attempts.clear(); + toolReports.clear(); + autoFailed.clear(); +} diff --git a/packages/core/src/remote/runners.ts b/packages/core/src/remote/runners.ts index fcb7bbc..e686d9d 100644 --- a/packages/core/src/remote/runners.ts +++ b/packages/core/src/remote/runners.ts @@ -8,6 +8,7 @@ * chat's browser sessions are copied * health the runner's own checks, passed through, with a summary kept for the list * autofix a local chat whose agent may run commands on the runner to repair it + * updates the runner's Godmode brought to this computer's, its tools to their newest (runnerUpdates.ts) */ import { createHash } from "node:crypto"; import { @@ -44,6 +45,7 @@ import { RemoteLink, type LinkState } from "./linkClient"; import { mergeMemory, readMemoryState, writeMemoryState } from "./memorySync"; import { adoptChat, applyRunnerEvent, catchUp, runnerDisconnected, setMirrorHooks } from "./mirror"; import { cancelOffer, createOffer } from "./pairing"; +import { forgetRunnerUpdates, maybeAutoUpdate, prepareRunnerUpdates, resetRunnerUpdates, runnerConnected, runnerUpdate, setUpdateHost, updateRunnerSoftware } from "./runnerUpdates"; import { buildSnapshot, snapshotDigest } from "./snapshot"; import { requireLicense } from "../license/license"; @@ -54,6 +56,8 @@ const SETUP_ENTITIES = new Set(["workspaces", "agents", "credentials", "totp", " const SYNC_DEBOUNCE_MS = 5_000; const SYNC_TIMEOUT_MS = 5 * 60_000; const MAX_ADDRESSES = 10; +/** Runners with auto-update are looked at this often besides their connects (one that had to wait for its chats). */ +const AUTO_UPDATE_EVERY_MS = 15 * 60_000; const ADDRESS = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}[A-Za-z0-9])?)(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}[A-Za-z0-9])?)*$|^[0-9a-fA-F:]{2,45}$/; interface RunnerRow { @@ -67,6 +71,7 @@ interface RunnerRow { arch: string | null; version: string | null; sync_browser: number; + auto_update: number; last_address: string | null; last_seen_at: string | null; synced_at: string | null; @@ -90,6 +95,7 @@ let syncTimer: ReturnType | null = null; let forceNextSync = false; let offBus: (() => void) | null = null; let started = false; +let autoUpdateTimer: ReturnType | null = null; /* ------------------------------------------------------------------ */ /* Registry */ @@ -137,6 +143,7 @@ function toRunner(r: RunnerRow): RemoteRunner { platform: r.platform, arch: r.arch, version: r.version, + build: infos.get(r.id)?.build ?? null, addresses: parseJson(r.addresses, []), port: r.port, fingerprint: fingerprint(r.public_key), @@ -155,6 +162,7 @@ function toRunner(r: RunnerRow): RemoteRunner { activeRuns: remoteRuns(r.id).filter((x) => x.status !== "paused").length, conversations: get<{ c: number }>("SELECT COUNT(*) AS c FROM conversations WHERE runner_id = ?", r.id)?.c ?? 0, syncBrowser: r.sync_browser === 1, + update: runnerUpdate(r, state.state), }; } @@ -202,11 +210,20 @@ export function updateRunner(id: string, patch: RunnerPatch): RemoteRunner { addresses: addresses ? JSON.stringify(addresses) : undefined, port: patch.port, sync_browser: patch.syncBrowser === undefined ? undefined : patch.syncBrowser ? 1 : 0, + auto_update: patch.autoUpdate === undefined ? undefined : patch.autoUpdate ? 1 : 0, updated_at: now(), }); links.get(id)?.update({ ...(addresses ? { addresses } : {}), ...(patch.port ? { port: patch.port } : {}), ...(name ? { name } : {}) }); audit("user", "runner.update", id, { name: name ?? r.name }); emit(id); + if (patch.autoUpdate) void maybeAutoUpdate(id); + return getRunner(id); +} + +/** Bring the runner's Godmode to this computer's and (with `tools`) its tools to their newest versions. */ +export async function updateRunnerNow(id: string, opts: { tools?: boolean } = {}): Promise { + requireRow(id); + await updateRunnerSoftware(id, opts); return getRunner(id); } @@ -223,6 +240,7 @@ export async function removeRunner(id: string): Promise { infos.delete(id); healths.delete(id); subscriptions.delete(id); + forgetRunnerUpdates(id); runnerDisconnected(id); const ts = now(); const chats = all<{ id: string }>("SELECT id FROM conversations WHERE runner_id = ?", id).map((c) => c.id); @@ -377,6 +395,7 @@ async function connected(id: string, state: LinkState) { } catch (err) { log.warn(`could not check runner ${id}`, err instanceof Error ? err.message : err); } + await runnerConnected(id).catch((err) => log.warn(`could not look at the updates of runner ${id}`, err instanceof Error ? err.message : err)); } let hubWired = false; @@ -421,6 +440,28 @@ export function startRunners(): void { if (started) return; started = true; wireHub(); + setUpdateHost({ + row, + link: (id) => links.get(id) ?? null, + info: (id) => infos.get(id), + setInfo: (id, info) => { + infos.set(id, info); + emit(id); + }, + activeRuns: (id) => remoteRuns(id).filter((x) => x.status !== "paused").length, + emit, + }); + // Until this program's digest is known every runner looks current: tell the UI (and auto-update) once it is. + void prepareRunnerUpdates().then(() => { + for (const id of links.keys()) { + emit(id); + void maybeAutoUpdate(id); + } + }); + autoUpdateTimer = setInterval(() => { + for (const id of links.keys()) void maybeAutoUpdate(id); + }, AUTO_UPDATE_EVERY_MS); + autoUpdateTimer.unref?.(); for (const r of all("SELECT * FROM runners")) startLink(r); setMirrorHooks({ runFinished: (runnerId, run) => { @@ -437,6 +478,10 @@ export function stopRunners(): void { started = false; offBus?.(); offBus = null; + if (autoUpdateTimer) clearInterval(autoUpdateTimer); + autoUpdateTimer = null; + resetRunnerUpdates(); + setUpdateHost(null); if (syncTimer) clearTimeout(syncTimer); syncTimer = null; for (const l of links.values()) l.stop(); diff --git a/packages/core/src/remote/selfUpdate.ts b/packages/core/src/remote/selfUpdate.ts new file mode 100644 index 0000000..64ffbc1 --- /dev/null +++ b/packages/core/src/remote/selfUpdate.ts @@ -0,0 +1,323 @@ +/** + * A runner replacing its own Godmode with the one its controller runs. + * + * The new program arrives in pieces over the link (`receiveChunk`) or is downloaded from usegodmode.com + * (`downloadUpdate`, for a runner on another platform than its controller). It is staged next to the running + * executable, checked against its SHA-256, started once with `version`, and only then renamed over the executable — a + * rename, so the running process keeps its old file and a broken download never replaces a working program. The swap + * waits until no run works; then the runner restarts: under launchd it exits with EX_TEMPFAIL and KeepAlive starts the + * new program, otherwise a small shell waits for this process to end and starts the new one with the same arguments. + * + * The build it installed is noted (meta `update.pending`); the restarted runner compares it with its own and reports + * a mismatch as a failed update. + */ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, renameSync, rmSync, statSync, writeSync } from "node:fs"; +import { arch, platform } from "node:os"; +import { dirname, join } from "node:path"; +import { LICENSE_SITE, type RunnerSelfUpdate } from "@godmode/shared"; +import { BUILD, COMPILED, VERSION, config } from "../config"; +import { deleteMeta, getMeta, setMeta } from "../db"; +import { logger } from "../log"; +import { listActiveRuns } from "../runner/runner"; +import { audit } from "../services/audit"; +import { runCommand } from "../services/doctor"; +import { HttpError, parseJson } from "../util"; +import { SERVICE_LABEL } from "./launchd"; + +const log = logger("self-update"); + +/** launchd's KeepAlive starts a runner again that exits with anything but 0. */ +export const RESTART_EXIT_CODE = 75; +const PENDING_META = "update.pending"; +const IDLE_POLL_MS = 10_000; +const VERIFY_TIMEOUT_MS = 60_000; +const DOWNLOAD_TIMEOUT_MS = 15 * 60_000; +export const MAX_UPDATE_BYTES = 1024 ** 3; + +export interface UpdateTarget { + version: string; + build: string; +} + +interface State { + state: RunnerSelfUpdate["state"]; + target: UpdateTarget | null; + error: string | null; +} + +let state: State = { state: "idle", target: null, error: null }; +let received = 0; +let idleTimer: ReturnType | null = null; +let restartHandler: (() => void) | null = null; +let digest: Promise | null = null; +let knownDigest: string | null = null; + +/** index.ts: how this process stops so it can come back as the new program. */ +export function setRestartHandler(fn: (() => void) | null): void { + restartHandler = fn; +} + +let executableOverride: string | null = null; + +/** The program a runner would replace: the compiled executable, or null when Godmode runs from source. */ +export function ownExecutable(): string | null { + return executableOverride ?? (COMPILED ? process.execPath : null); +} + +/** SHA-256 of this program; computed once, in the background. */ +export function executableDigest(): Promise | null { + const path = ownExecutable(); + if (!path) return null; + digest ??= (async () => { + const hash = createHash("sha256"); + const reader = Bun.file(path).stream().getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + } + knownDigest = hash.digest("hex"); + return knownDigest; + })().catch((err) => { + digest = null; + throw err; + }); + return digest; +} + +/** The digest once it is known (RunnerInfo is answered without waiting for it). */ +export function knownExecutableDigest(): string | null { + if (!knownDigest) void executableDigest()?.catch(() => undefined); + return knownDigest; +} + +function stagePath(): string { + const exe = ownExecutable(); + if (!exe) throw new HttpError(409, "This runner runs Godmode from its sources — update it with git there.", "runner_from_source"); + return join(dirname(exe), ".godmode-update"); +} + +function set(patch: Partial) { + state = { ...state, ...patch }; +} + +export function selfUpdateStatus(): RunnerSelfUpdate { + return { ...state, waitingFor: state.state === "waiting" ? listActiveRuns().length : 0 }; +} + +function fail(message: string, code = "update_failed"): never { + set({ state: "failed", error: message }); + log.warn(`update failed: ${message}`); + throw new HttpError(422, message, code); +} + +/** Forget a half-received update (another one starts, or the controller gave up). */ +function resetStage() { + if (idleTimer) clearInterval(idleTimer); + idleTimer = null; + received = 0; + try { + rmSync(stagePath(), { force: true }); + } catch { + /* from source: nothing staged */ + } +} + +/** One piece of the new program, in order. `offset` 0 starts a new update. */ +export function receiveChunk(offset: number, total: number, bytes: Uint8Array): { received: number } { + if (!Number.isInteger(total) || total <= 0 || total > MAX_UPDATE_BYTES) throw new HttpError(400, "That isn't a size Godmode can be.", "bad_request"); + if (state.state === "installing") throw new HttpError(409, "An update is being installed right now.", "update_busy"); + const path = stagePath(); + if (offset === 0) { + resetStage(); + set({ state: "receiving", target: null, error: null }); + } else if (offset !== received || state.state !== "receiving") { + throw new HttpError(409, `Expected the piece at ${received}, got ${offset}.`, "update_out_of_order"); + } + if (offset + bytes.byteLength > total) throw new HttpError(400, "More than announced.", "bad_request"); + const fd = openSync(path, offset === 0 ? "w" : "a", 0o600); + try { + writeSync(fd, bytes); + } finally { + closeSync(fd); + } + received = offset + bytes.byteLength; + return { received }; +} + +async function fileDigest(path: string): Promise { + const hash = createHash("sha256"); + const reader = Bun.file(path).stream().getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + } + return hash.digest("hex"); +} + +/** The staged program is the one announced and starts on this computer. */ +async function verifyStage(sha256: string, size: number, target: UpdateTarget): Promise { + const path = stagePath(); + if (!existsSync(path)) fail("The new Godmode didn't arrive. Try again."); + if (statSync(path).size !== size) fail("The new Godmode arrived incomplete. Try again."); + if ((await fileDigest(path)) !== sha256.toLowerCase()) { + rmSync(path, { force: true }); + fail("The new Godmode arrived damaged (its checksum doesn't match). Try again."); + } + chmodSync(path, 0o755); + if (platform() === "darwin") await runCommand(["/usr/bin/xattr", "-d", "com.apple.quarantine", path], { timeoutMs: 10_000 }); + const res = await runCommand([path, "version"], { timeoutMs: VERIFY_TIMEOUT_MS }); + const said = res.stdout.trim().split("\n").pop()?.trim() ?? ""; + if (res.code !== 0 || said !== target.version) { + rmSync(path, { force: true }); + fail(`The new Godmode doesn't start on this computer${res.stderr.trim() ? `: ${res.stderr.trim().split("\n").pop()}` : "."}`); + } +} + +/** + * Install what was staged once nothing works: the controller sent all of it (`receiveChunk`) or it was downloaded. + * Resolves with what the runner does now ("waiting" for its runs, or "installing" — it restarts right after answering). + */ +export async function applyUpdate(input: { sha256: string; size: number; target: UpdateTarget }): Promise { + if (state.state === "installing") throw new HttpError(409, "An update is being installed right now.", "update_busy"); + set({ state: "installing", target: input.target, error: null }); + await verifyStage(input.sha256, input.size, input.target); + if (listActiveRuns().length) { + set({ state: "waiting" }); + log.info(`update to ${input.target.version} (${input.target.build}) waits for ${listActiveRuns().length} run(s)`); + if (idleTimer) clearInterval(idleTimer); + idleTimer = setInterval(() => { + if (listActiveRuns().length || state.state !== "waiting") return; + clearInterval(idleTimer!); + idleTimer = null; + swap(input.target); + }, IDLE_POLL_MS); + idleTimer.unref?.(); + return selfUpdateStatus(); + } + setTimeout(() => swap(input.target), 300); + return selfUpdateStatus(); +} + +function swap(target: UpdateTarget) { + const exe = ownExecutable(); + if (!exe) return; + try { + set({ state: "installing" }); + renameSync(stagePath(), exe); + } catch (err) { + set({ state: "failed", error: `Couldn't replace ${exe}: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + setMeta(PENDING_META, JSON.stringify({ ...target, from: { version: VERSION, build: BUILD } })); + audit("controller", "runner.upgrade", null, { from: `${VERSION} ${BUILD}`, to: `${target.version} ${target.build}` }); + log.info(`installed Godmode ${target.version} (${target.build}), restarting`); + restart(); +} + +/** Under launchd: exit so KeepAlive starts the new program. Otherwise: a shell starts it once this process is gone. */ +function restart() { + const underLaunchd = process.env.XPC_SERVICE_NAME === SERVICE_LABEL; + if (!underLaunchd) { + const exe = ownExecutable()!; + const logs = join(config().dataDir, "logs"); + mkdirSync(logs, { recursive: true }); + const out = openSync(join(logs, "service.log"), "a"); + const child = spawn("/bin/sh", ["-c", 'while kill -0 "$0" 2>/dev/null; do sleep 0.2; done; exec "$@"', String(process.pid), exe, ...process.argv.slice(2)], { + detached: true, + stdio: ["ignore", out, out], + env: process.env, + }); + child.unref(); + } + if (restartHandler) restartHandler(); + else process.exit(RESTART_EXIT_CODE); +} + +/** The server binary of this computer on usegodmode.com (`godmode-darwin-arm64`), or null when there is none. */ +export function releaseAsset(os: string = platform(), cpu: string = arch()): string | null { + const name = os === "darwin" ? "darwin" : os === "linux" ? "linux" : null; + const bits = cpu === "arm64" ? "arm64" : cpu === "x64" ? "x64" : null; + return name && bits ? `godmode-${name}-${bits}` : null; +} + +async function fetchText(url: string): Promise { + const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!res.ok) throw new Error(`${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`.trim()); + return res.text(); +} + +/** + * Download the release `version` for this computer from usegodmode.com (with the controller's licence key) and + * install it like one that was sent. + */ +export async function downloadUpdate(input: { key: string; target: UpdateTarget; site?: string }): Promise { + const asset = releaseAsset(); + if (!asset) fail(`usegodmode.com has no Godmode for ${platform()} ${arch()}.`, "update_unsupported"); + if (state.state === "installing" || state.state === "receiving") throw new HttpError(409, "An update is being installed right now.", "update_busy"); + resetStage(); + set({ state: "receiving", target: input.target, error: null }); + const query = `key=${encodeURIComponent(input.key)}&version=${encodeURIComponent(`v${input.target.version.replace(/^v/, "")}`)}`; + const base = `${(input.site ?? LICENSE_SITE).replace(/\/+$/, "")}/download/file/${asset}`; + let sha256: string; + try { + sha256 = (await fetchText(`${base}.sha256?${query}`)).trim().split(/\s+/)[0] ?? ""; + if (!/^[0-9a-f]{64}$/i.test(sha256)) throw new Error("no checksum"); + } catch (err) { + fail(`usegodmode.com has no checksum for Godmode ${input.target.version} (${err instanceof Error ? err.message : String(err)}).`); + } + const path = stagePath(); + let size = 0; + try { + const res = await fetch(`${base}?${query}`, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!res.ok || !res.body) throw new Error(`${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`.trim()); + const fd = openSync(path, "w", 0o600); + try { + const reader = res.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_UPDATE_BYTES) throw new Error("too large"); + writeSync(fd, value); + } + } finally { + closeSync(fd); + } + } catch (err) { + rmSync(path, { force: true }); + fail(`Couldn't download Godmode ${input.target.version}: ${err instanceof Error ? err.message : String(err)}`); + } + set({ state: "idle" }); + return applyUpdate({ sha256, size, target: input.target }); +} + +/** At start: did the update this runner restarted for take? A mismatch is reported like any failed update. */ +export function settleUpdate(): void { + const raw = getMeta(PENDING_META); + if (!raw) return; + deleteMeta(PENDING_META); + const pending = parseJson<(UpdateTarget & { from?: UpdateTarget }) | null>(raw, null); + if (!pending) return; + // A release from usegodmode.com is named by its version only (build ""). + if (pending.version === VERSION && (!pending.build || pending.build === BUILD)) { + log.info(`now running Godmode ${VERSION} (${BUILD})`); + return; + } + state = { state: "failed", target: { version: pending.version, build: pending.build }, error: `Restarted as Godmode ${VERSION} (${BUILD}) instead of ${pending.version} (${pending.build}).` }; + log.warn(state.error!); +} + +/** Tests: start from nothing; `executable` stands in for the compiled program. */ +export function __resetSelfUpdateForTests(executable: string | null = null): void { + executableOverride = executable; + digest = null; + knownDigest = null; + if (idleTimer) clearInterval(idleTimer); + idleTimer = null; + received = 0; + state = { state: "idle", target: null, error: null }; +} diff --git a/packages/core/src/server/routes/link.ts b/packages/core/src/server/routes/link.ts index fb333d4..a7b88c9 100644 --- a/packages/core/src/server/routes/link.ts +++ b/packages/core/src/server/routes/link.ts @@ -6,10 +6,13 @@ import { logger } from "../../log"; import { fixCheck, runnerHealth } from "../../remote/health"; import { forgetController, runnerInfo } from "../../remote/linkServer"; import { readMemoryState, writeMemoryState } from "../../remote/memorySync"; +import { MAX_UPDATE_BYTES, applyUpdate, downloadUpdate, receiveChunk } from "../../remote/selfUpdate"; import { applySnapshot, type ConfigSnapshot } from "../../remote/snapshot"; import { audit } from "../../services/audit"; +import { installUpdates } from "../../services/maintenance"; +import { checkUpdates } from "../../services/updates"; import { runCommand, toolPath } from "../../services/doctor"; -import { childEnv, notFound } from "../../util"; +import { badRequest, childEnv, notFound } from "../../util"; import { body, z } from "../validate"; const log = logger("link"); @@ -82,6 +85,30 @@ export function registerLinkRoutes(app: Hono): void { return c.json(res); }); + // A new Godmode from the controller, in pieces (remote/selfUpdate.ts), then installed once nothing works. + app.put("/api/link/update/chunk", async (c) => { + const offset = Number(c.req.query("offset")); + const total = Number(c.req.query("total")); + if (!Number.isInteger(offset) || offset < 0 || offset > MAX_UPDATE_BYTES) throw badRequest("offset"); + return c.json(receiveChunk(offset, total, new Uint8Array(await c.req.arrayBuffer()))); + }); + + const target = z.object({ version: z.string().min(1).max(64), build: z.string().max(128) }); + + app.post("/api/link/update/apply", async (c) => { + const input = await body(c, z.object({ sha256: z.string().regex(/^[0-9a-fA-F]{64}$/), size: z.number().int().positive().max(MAX_UPDATE_BYTES), target })); + return c.json(await applyUpdate(input)); + }); + + app.post("/api/link/update/download", async (c) => { + const input = await body(c, z.object({ key: z.string().min(1).max(200), target, site: z.string().url().max(200).optional() })); + return c.json(await downloadUpdate(input)); + }); + + app.get("/api/link/updates", async (c) => c.json(await checkUpdates(c.req.query("refresh") === "1"))); + + app.post("/api/link/updates/install", async (c) => c.json(await installUpdates())); + app.post("/api/link/forget", (c) => { const controllerId = viaLink(c); // Answered first: forgetting closes the link this answer travels on. diff --git a/packages/core/src/server/routes/runners.ts b/packages/core/src/server/routes/runners.ts index 8655681..b64ea33 100644 --- a/packages/core/src/server/routes/runners.ts +++ b/packages/core/src/server/routes/runners.ts @@ -12,6 +12,7 @@ import { startAutofix, syncRunner, updateRunner, + updateRunnerNow, } from "../../remote/runners"; import { body, z } from "../validate"; @@ -42,6 +43,7 @@ export function registerRunnerRoutes(app: Hono): void { addresses: z.array(z.string().max(253)).max(10).optional(), port: z.number().int().min(1).max(65535).optional(), syncBrowser: z.boolean().optional(), + autoUpdate: z.boolean().optional(), }), ); return c.json(updateRunner(c.req.param("id"), patch)); @@ -60,6 +62,12 @@ export function registerRunnerRoutes(app: Hono): void { return c.json(getRunner(id)); }); + // Its Godmode to this computer's, its tools to their newest; the progress follows as runner.updated. + app.post("/api/runners/:id/update", async (c) => { + const input = await body(c, z.object({ tools: z.boolean().optional() })); + return c.json(await updateRunnerNow(c.req.param("id"), input)); + }); + app.get("/api/runners/:id/health", async (c) => c.json(await runnerHealth(c.req.param("id"), c.req.query("refresh") === "1"))); app.post("/api/runners/:id/health/fix", async (c) => { diff --git a/packages/core/test/remote-contract.test.ts b/packages/core/test/remote-contract.test.ts index 9b66275..37eb7f6 100644 --- a/packages/core/test/remote-contract.test.ts +++ b/packages/core/test/remote-contract.test.ts @@ -201,6 +201,7 @@ describe("a fresh installation", () => { "sync_error", "created_at", "updated_at", + "auto_update", ]); expect(columns("link_controllers")).toEqual(["id", "name", "public_key", "last_seen_at", "last_address", "created_at"]); expect(columns("runner_memory")).toEqual(["runner_id", "agent_id", "digest", "snapshot", "updated_at"]); diff --git a/packages/core/test/remote-e2e.test.ts b/packages/core/test/remote-e2e.test.ts index 62c96e7..304417d 100644 --- a/packages/core/test/remote-e2e.test.ts +++ b/packages/core/test/remote-e2e.test.ts @@ -285,6 +285,19 @@ describe("a runner, end to end", () => { expect(existsSync(join(runnerDir, "fake-claude", "finish"))).toBe(true); }, 90_000); + test("the runner tells its build and tools; one running from source refuses a new program with a clear answer", async () => { + const runner = getRunner(runnerId); + expect(runner.build).toBeTruthy(); + expect(runner.update).toMatchObject({ state: "current", autoUpdate: true, target: { version: runner.version } }); + const report = await link(runnerId).json<{ tools: unknown[] }>("GET", "/api/link/updates"); + expect(Array.isArray(report.tools)).toBe(true); + const res = await link(runnerId).request("PUT", "/api/link/update/chunk?offset=0&total=4", { body: new Uint8Array([1, 2, 3, 4]), headers: { "content-type": "application/octet-stream" } }); + expect(res.status).toBe(409); + expect(JSON.parse(Buffer.from(res.body).toString("utf8"))).toMatchObject({ code: "runner_from_source" }); + const off = await api(`/api/runners/${runnerId}`, { method: "PATCH", body: JSON.stringify({ autoUpdate: false }) }); + expect(((await off.json()) as { update: { autoUpdate: boolean } }).update.autoUpdate).toBe(false); + }, 120_000); + test("removing the runner makes it forget this computer; its chats stay here as this computer's", async () => { const chats = all<{ id: string }>("SELECT id FROM conversations WHERE runner_id = ?", runnerId).map((c) => c.id); expect(chats.length).toBeGreaterThan(0); diff --git a/packages/core/test/runner-updates.test.ts b/packages/core/test/runner-updates.test.ts new file mode 100644 index 0000000..fe4f19f --- /dev/null +++ b/packages/core/test/runner-updates.test.ts @@ -0,0 +1,167 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RunnerInfo } from "@godmode/shared"; +import { BUILD, VERSION, loadConfig } from "../src/config"; +import { closeDb, deleteMeta, getMeta, openDb, setMeta } from "../src/db"; +import { setLogLevel } from "../src/log"; +import { __setControllerForTests, bridgeScript, planUpdate } from "../src/remote/runnerUpdates"; +import { __resetSelfUpdateForTests, applyUpdate, receiveChunk, releaseAsset, selfUpdateStatus, setRestartHandler, settleUpdate } from "../src/remote/selfUpdate"; +import { HttpError, sleep } from "../src/util"; + +let dir: string; + +beforeAll(() => { + setLogLevel("error"); + dir = mkdtempSync(join(tmpdir(), "godmode-runner-updates-")); + loadConfig({ dataDir: dir, role: "runner" }); + openDb(join(dir, "godmode.db")); +}); + +afterAll(() => { + __resetSelfUpdateForTests(); + __setControllerForTests(null); + setRestartHandler(null); + closeDb(); + rmSync(dir, { recursive: true, force: true }); +}); + +const here = { platform: process.platform, arch: process.arch }; +const info = (patch: Partial = {}): RunnerInfo => ({ + name: "Mac mini", + hostname: "mini", + platform: process.platform, + arch: process.arch, + version: VERSION, + build: BUILD, + compiled: true, + digest: "a".repeat(64), + protocol: 1, + vault: { initialized: true, unlocked: true }, + configDigest: null, + activeRuns: 0, + ...patch, +}); + +describe("which Godmode a runner needs", () => { + beforeEach(() => { + __setControllerForTests(null); + deleteMeta("license.key"); + }); + + test("the same program as this computer needs nothing; another one gets this computer's over the link", () => { + __setControllerForTests({ digest: "a".repeat(64) }); + expect(planUpdate({ ...here, version: VERSION }, info()).needed).toBe(false); + const plan = planUpdate({ ...here, version: VERSION }, info({ digest: "b".repeat(64) })); + expect(plan).toMatchObject({ needed: true, source: "controller" }); + }); + + test("a runner from before the updater fetches this computer's program once on a Mac, else gets a command", () => { + __setControllerForTests({ digest: "a".repeat(64) }); + const legacy = info({ build: undefined, compiled: undefined, digest: undefined, update: undefined }); + if (process.platform === "darwin") expect(planUpdate({ ...here, version: VERSION }, legacy)).toMatchObject({ needed: true, source: "bridge" }); + const linux = planUpdate({ platform: "linux", arch: "x64", version: "0.0.1" }, { ...legacy, platform: "linux", arch: "x64", version: "0.0.1" }); + expect(linux.source).toBeNull(); + expect(linux.command).toContain("usegodmode.com/runner.sh"); + }); + + test("another kind of computer downloads the release of this version, with the licence key", () => { + const r = { platform: "linux", arch: "arm64", version: "0.0.1" }; + const without = planUpdate(r, info({ ...r })); + expect(without).toMatchObject({ needed: true, source: null }); + expect(without.reason).toContain("licence key"); + setMeta("license.key", "GM-AAAAA-BBBBB-CCCCC-DDDDD"); + expect(planUpdate(r, info({ ...r }))).toMatchObject({ needed: true, source: "website" }); + // Across platforms only the version counts: the builds always differ. + expect(planUpdate({ ...r, version: VERSION }, info({ ...r, version: VERSION, build: "other" })).needed).toBe(false); + }); + + test("never back to an older Godmode, nothing for a runner from source", () => { + __setControllerForTests({ digest: "a".repeat(64) }); + expect(planUpdate({ ...here, version: "99.0.0" }, info({ version: "99.0.0", digest: "b".repeat(64) })).needed).toBe(false); + const source = planUpdate({ ...here, version: "0.0.1" }, info({ version: "0.0.1", compiled: false, digest: null })); + expect(source).toMatchObject({ needed: true, source: null, command: null }); + }); + + test("an unknown version (never connected) needs nothing yet", () => { + expect(planUpdate({ ...here, version: null }, undefined).needed).toBe(false); + }); +}); + +describe("a runner installing a new Godmode", () => { + let exe: string; + let restarts = 0; + + /** A stand-in program that answers `version` like Godmode. */ + const program = (version: string) => Buffer.from(`#!/bin/sh\n[ "$1" = version ] && echo ${version}\n`); + const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex"); + + beforeEach(() => { + exe = join(mkdtempSync(join(dir, "bin-")), "godmode"); + writeFileSync(exe, program("0.0.1")); + chmodSync(exe, 0o755); + __resetSelfUpdateForTests(exe); + restarts = 0; + setRestartHandler(() => restarts++); + deleteMeta("update.pending"); + }); + + test("takes the pieces in order, checks them, swaps the program and restarts", async () => { + const next = program(VERSION); + const half = Math.ceil(next.byteLength / 2); + expect(receiveChunk(0, next.byteLength, next.subarray(0, half))).toEqual({ received: half }); + expect(() => receiveChunk(0 + 1, next.byteLength, next.subarray(half))).toThrow(HttpError); + receiveChunk(half, next.byteLength, next.subarray(half)); + const status = await applyUpdate({ sha256: sha(next), size: next.byteLength, target: { version: VERSION, build: BUILD } }); + expect(status.state).toBe("installing"); + await sleep(600); + expect(readFileSync(exe).equals(next)).toBe(true); + expect(existsSync(join(exe, "..", ".godmode-update"))).toBe(false); + expect(restarts).toBe(1); + expect(JSON.parse(getMeta("update.pending")!)).toMatchObject({ version: VERSION, build: BUILD }); + // The restarted runner is that build: nothing to report. + settleUpdate(); + expect(selfUpdateStatus().state).not.toBe("failed"); + expect(getMeta("update.pending")).toBeNull(); + }); + + test("a damaged program never replaces the working one", async () => { + const next = program(VERSION); + receiveChunk(0, next.byteLength, next); + await expect(applyUpdate({ sha256: "0".repeat(64), size: next.byteLength, target: { version: VERSION, build: BUILD } })).rejects.toThrow(/damaged/); + expect(readFileSync(exe).equals(program("0.0.1"))).toBe(true); + expect(selfUpdateStatus()).toMatchObject({ state: "failed" }); + expect(restarts).toBe(0); + }); + + test("a program that doesn't say the announced version is refused", async () => { + const next = program("9.9.9"); + receiveChunk(0, next.byteLength, next); + await expect(applyUpdate({ sha256: sha(next), size: next.byteLength, target: { version: VERSION, build: BUILD } })).rejects.toThrow(/doesn't start/); + expect(readFileSync(exe).equals(program("0.0.1"))).toBe(true); + }); + + test("a restart that came back as another build is reported as failed", () => { + setMeta("update.pending", JSON.stringify({ version: VERSION, build: "abc1234 2026-01-01" })); + settleUpdate(); + expect(selfUpdateStatus()).toMatchObject({ state: "failed", target: { build: "abc1234 2026-01-01" } }); + }); +}); + +describe("downloads and the bridge", () => { + test("each computer has its server binary on usegodmode.com", () => { + expect(releaseAsset("darwin", "arm64")).toBe("godmode-darwin-arm64"); + expect(releaseAsset("linux", "x64")).toBe("godmode-linux-x64"); + expect(releaseAsset("win32", "x64")).toBeNull(); + }); + + test("the bridge script checks the download before it replaces the service's program", () => { + const script = bridgeScript(["http://192.168.1.2:4000/tok/godmode", "http://100.64.0.1:4000/tok/godmode"], "f".repeat(64)); + expect(script).toContain("'http://192.168.1.2:4000/tok/godmode' 'http://100.64.0.1:4000/tok/godmode'"); + expect(script.indexOf("shasum -a 256")).toBeLessThan(script.indexOf('mv -f "$tmp" "$bin"')); + expect(script).toContain("f".repeat(64)); + expect(script).toContain("launchctl kickstart -k"); + }); +}); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 75f15be..3ac5dab 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -4,7 +4,7 @@ * it and mirrors its chats; everything between the two travels over an end-to-end encrypted link. */ import type { TailscaleStatus } from "./mobile"; -import type { ID, ISODate } from "./models"; +import type { ID, ISODate, ToolUpdateStatus } from "./models"; /** Port the runner listens on for the encrypted link (phones use 7787). */ export const RUNNER_DEFAULT_PORT = 7788; @@ -37,6 +37,8 @@ export interface RemoteRunner { arch: string | null; /** Godmode version running there; null until first connected. */ version: string | null; + /** The commit its Godmode was built from ("dev" from source); null for a runner older than its updater. */ + build: string | null; /** Hosts or IPs to dial, best first (LAN address, Tailscale address, `name.local`). */ addresses: string[]; port: number; @@ -58,6 +60,7 @@ export interface RemoteRunner { conversations: number; /** Copy the browser sessions (cookies) of the profile a chat uses before it starts. */ syncBrowser: boolean; + update: RunnerUpdate; } export interface RunnerPatch { @@ -65,6 +68,56 @@ export interface RunnerPatch { addresses?: string[]; port?: number; syncBrowser?: boolean; + autoUpdate?: boolean; +} + +/** + * Bringing a runner's Godmode to the one this computer runs, and its tools to their newest versions. + * + * current the same Godmode as here (tools may still have updates: `tools`) + * available another build than here; `Update` installs this computer's + * sending the new Godmode is on its way over the link (`progress`) + * waiting it is there and installs once the runner's runs are done + * installing the runner replaces its program, or updates its tools + * restarting it restarted with the new Godmode and comes back in a moment + * failed the last attempt didn't work (`detail`); `Update` tries again + * unsupported can't be updated from here (`detail` says what to do instead) + */ +export type RunnerUpdateState = "current" | "available" | "sending" | "waiting" | "installing" | "restarting" | "failed" | "unsupported"; + +/** How the new Godmode gets there: this computer's own program, a download from usegodmode.com, or (runners from before the updater) fetched from here once. */ +export type RunnerUpdateSource = "controller" | "website" | "bridge"; + +export interface RunnerUpdate { + state: RunnerUpdateState; + /** The Godmode it gets: this computer's. */ + target: { version: string; build: string }; + source: RunnerUpdateSource | null; + /** 0..1 while the new Godmode is sent. */ + progress: number | null; + detail: string | null; + /** Install a new Godmode by itself once it connects (and its runs are done). */ + autoUpdate: boolean; + /** Tools on the runner with an update it can install (Claude Code, uv, Chromium…), from its last report. */ + tools: Pick[]; + /** A command to run on the runner when it can't be updated from here; null otherwise. */ + command: string | null; +} + +/** POST /api/runners/:id/update */ +export interface RunnerUpdateInput { + /** Also install the runner's tool updates (default true). */ + tools?: boolean; +} + +/** What a runner says about the update it is installing (in RunnerInfo). */ +export interface RunnerSelfUpdate { + state: "idle" | "receiving" | "waiting" | "installing" | "failed"; + /** The build it installs (or failed to). */ + target: { version: string; build: string } | null; + error: string | null; + /** Runs it waits for. */ + waitingFor: number; } /** How a runner reaches this computer: the local network, a virtual machine's bridge, or Tailscale. */ @@ -134,6 +187,13 @@ export interface RunnerInfo { platform: string; arch: string; version: string; + /** Missing on runners older than their updater. */ + build?: string; + /** One compiled program (can replace itself); false when it runs from source. */ + compiled?: boolean; + /** SHA-256 of that program, once known: two runners with the same digest run the same Godmode. */ + digest?: string | null; + update?: RunnerSelfUpdate; protocol: number; vault: { initialized: boolean; unlocked: boolean }; /** Digest of the config snapshot it last applied; null = never synced. */