@@ -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. */