From 9036334eb7fde6de9238f918685ed1cf64644517 Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Wed, 7 Oct 2026 08:00:52 +0200
Subject: [PATCH 1/3] Let runners update themselves to this computer's Godmode
The controller sends its own program to a runner over the link in pieces (or the
runner downloads the same release from usegodmode.com), the runner checks it,
swaps it in once its runs are done and restarts. Runners from before the updater
fetch it once through runner_exec. Auto-update per runner, on by default; tool
updates on the runner go through the same request.
---
packages/core/src/db/migrations.ts | 8 +
packages/core/src/index.ts | 9 +-
packages/core/src/license/license.ts | 5 +
packages/core/src/remote/linkServer.ts | 7 +-
packages/core/src/remote/runnerUpdates.ts | 464 +++++++++++++++++++++
packages/core/src/remote/runners.ts | 45 ++
packages/core/src/remote/selfUpdate.ts | 323 ++++++++++++++
packages/core/src/server/routes/link.ts | 29 +-
packages/core/src/server/routes/runners.ts | 8 +
packages/core/test/remote-contract.test.ts | 1 +
packages/core/test/remote-e2e.test.ts | 13 +
packages/core/test/runner-updates.test.ts | 167 ++++++++
packages/shared/src/remote.ts | 62 ++-
13 files changed, 1136 insertions(+), 5 deletions(-)
create mode 100644 packages/core/src/remote/runnerUpdates.ts
create mode 100644 packages/core/src/remote/selfUpdate.ts
create mode 100644 packages/core/test/runner-updates.test.ts
diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts
index 576ecb7a..e929a174 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 3bb7f819..70bebd94 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 4e3d372c..642dad9a 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 885e94d4..feca2b5e 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 00000000..233264dc
--- /dev/null
+++ b/packages/core/src/remote/runnerUpdates.ts
@@ -0,0 +1,464 @@
+/**
+ * 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. */
+function manualCommand(): string {
+ const key = licenseKey() ?? "GM-XXXXX-XXXXX-XXXXX-XXXXX";
+ return `curl -fsSL https://usegodmode.com/runner.sh | GODMODE_LICENSE=${key} GODMODE_VERSION=v${VERSION} sh`;
+}
+
+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: "Its Godmode is older than the updater. Install the new one there once with this command.", command: manualCommand(), key };
+ }
+ if (legacy) return { needed, source: null, reason: "Its Godmode is older than the updater. Install the new one there once with this command.", 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.", 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 fcb7bbc7..e686d9d2 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 00000000..64ffbc19
--- /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 fb333d45..a7b88c97 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 86556814..b64ea335 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 9b662751..37eb7f68 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 62c96e72..304417d5 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 00000000..fe4f19ff
--- /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 75f15be8..3ac5dabe 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. */
From 6d62ed526f988c9e31b44677abd0ea314841dc40 Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Wed, 7 Oct 2026 08:04:10 +0200
Subject: [PATCH 2/3] Show runner updates on the runner card with an Update
button
An update strip shows what the runner gets, the progress while it is sent, the
wait for its chats, the restart, failures with Try again and, where it can't be
updated from here, the command to run on it. The card menu gets Update
automatically and Update now / Check for updates.
---
.../src/components/runners/runner-card.tsx | 34 ++++-
.../src/components/runners/runner-parts.tsx | 13 +-
.../src/components/runners/runner-update.tsx | 117 ++++++++++++++++++
.../components/runners/use-runner-actions.ts | 26 +++-
apps/desktop/src/index.css | 5 +
apps/desktop/src/lib/api.ts | 3 +
docs/ARCHITECTURE.md | 15 +++
packages/core/src/remote/runnerUpdates.ts | 13 +-
8 files changed, 207 insertions(+), 19 deletions(-)
create mode 100644 apps/desktop/src/components/runners/runner-update.tsx
diff --git a/apps/desktop/src/components/runners/runner-card.tsx b/apps/desktop/src/components/runners/runner-card.tsx
index d7269500..6dd3075f 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 1c66528c..465af84f 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 00000000..1c7b20d2
--- /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 && (
+
actions.upgrade.mutate(runner)}>
+ {starting ? : u.state === "failed" ? : }
+ {u.state === "failed" ? "Try again" : "Update"}
+
+ )}
+
+ {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 2078478b..095e85cb 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 1197ee3c..fd09e077 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 5c811e05..f709960c 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 86e4da8a..e7ca990d 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/remote/runnerUpdates.ts b/packages/core/src/remote/runnerUpdates.ts
index 233264dc..efa7ec0c 100644
--- a/packages/core/src/remote/runnerUpdates.ts
+++ b/packages/core/src/remote/runnerUpdates.ts
@@ -92,12 +92,13 @@ export async function prepareRunnerUpdates(): Promise {
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 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 {
- const key = licenseKey() ?? "GM-XXXXX-XXXXX-XXXXX-XXXXX";
- return `curl -fsSL https://usegodmode.com/runner.sh | GODMODE_LICENSE=${key} GODMODE_VERSION=v${VERSION} sh`;
+ 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;
@@ -130,9 +131,9 @@ export function planUpdate(r: { platform: string | null; arch: string | null; ve
if (sameProgram && legacy) {
return r.platform === "darwin"
? { needed, source: "bridge", reason: null, command: null, key }
- : { needed, source: null, reason: "Its Godmode is older than the updater. Install the new one there once with this command.", command: manualCommand(), key };
+ : { needed, source: null, reason: ONCE_BY_HAND, command: manualCommand(), key };
}
- if (legacy) return { needed, source: null, reason: "Its Godmode is older than the updater. Install the new one there once with this command.", 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 };
@@ -146,7 +147,7 @@ function dueTools(id: string): RunnerUpdate["tools"] {
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.", command: manualCommand() };
+ 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);
From 2a2de519dac15cda1f255e6d70520481a86beabc Mon Sep 17 00:00:00 2001
From: Daniel Ehrhardt
Date: Wed, 7 Oct 2026 08:15:38 +0200
Subject: [PATCH 3/3] Keep runner updates on the computer, not the cloud
---
packages/core/src/cloud/scope.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts
index badc9d41..91bd0336 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],