diff --git a/apps/desktop/src/components/runners/add-runner-dialog.tsx b/apps/desktop/src/components/runners/add-runner-dialog.tsx index b9f674d9..46b37b0d 100644 --- a/apps/desktop/src/components/runners/add-runner-dialog.tsx +++ b/apps/desktop/src/components/runners/add-runner-dialog.tsx @@ -17,6 +17,7 @@ import { api, errorMessage } from "@/lib/api"; import { useRunners } from "@/lib/hooks"; import { onServerEvent, upsertRunner } from "@/lib/realtime"; import { cn } from "@/lib/utils"; +import { NetworkPicker, pickRoute } from "./network-picker"; import { RunnerHealthPanel, useRunnerHealth } from "./runner-health"; import { CommandBlock, THIS_COMPUTER, THIS_COMPUTER_INLINE, blockingChecks } from "./runner-parts"; import { useRunnerActions } from "./use-runner-actions"; @@ -38,6 +39,7 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC const [paired, setPaired] = useState(null); const [byCode, setByCode] = useState(false); const [source, setSource] = useState("local"); + const [address, setAddress] = useState(null); const now = useNow(1000); const create = useMutation({ mutationFn: api.runners.pairing, onSuccess: setOffer }); @@ -48,6 +50,7 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC setOffer(null); setByCode(false); setSource("local"); + setAddress(null); create.mutate(); const off = onServerEvent((e) => { if (e.type === "runner.paired") setPaired(e.runner); @@ -68,6 +71,10 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, expired, paired]); + const route = offer ? pickRoute(offer.routes, address) : null; + const command = route?.command ?? null; + const waitingOn = route?.url ?? offer?.urls[0] ?? null; + // The list is kept live: the paired runner's connection and health follow it. const runner = paired ? (runners?.find((r) => r.id === paired.id) ?? paired) : null; @@ -99,20 +106,41 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
    - A Mac mini, or a laptop that stays plugged in. It has to reach this computer: on the same network, or through Tailscale. + A Mac mini, or a laptop that stays plugged in. + + + {offer && !create.isError ? ( +
    + setAddress(r.address)} + onRefresh={() => create.mutate()} + refreshing={create.isPending} + /> +
    + ) : create.isError ? ( + "On the same network, or from anywhere through Tailscale." + ) : ( + + )}
    - + {offer && !create.isError ? (
    - {offer.command ? ( + {command ? ( setSource(v as Source)} className="gap-2.5"> Install from {THIS_COMPUTER_INLINE} Install from godmode.codext.de - -

    It downloads Godmode from this computer, installs it as a runner and pairs it. The command works once.

    + +

    + It downloads Godmode from this computer + {route?.network === "tailscale" ? " through Tailscale" : ""}, installs it as a runner and pairs it. The command works once. +

    @@ -141,7 +169,7 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC )} - + The runner appears here when the command finishes — usually under a minute. The first time, it also installs Claude Code and a browser.
@@ -151,12 +179,13 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC Waiting for the runner - {offer.urls[0] ? ( + {waitingOn ? ( <> {" "} - on {offer.urls[0].replace(/^https?:\/\//, "")} + on {waitingOn.replace(/^https?:\/\//, "")} ) : null} + {route?.network === "tailscale" ? " through Tailscale" : null} {[0, 1, 2].map((i) => ( diff --git a/apps/desktop/src/components/runners/network-picker.tsx b/apps/desktop/src/components/runners/network-picker.tsx new file mode 100644 index 00000000..ad28bf33 --- /dev/null +++ b/apps/desktop/src/components/runners/network-picker.tsx @@ -0,0 +1,190 @@ +import { useId } from "react"; +import { Box, ExternalLink, RefreshCw, Wifi } from "lucide-react"; +import type { RunnerNetwork, RunnerOfferRoute, TailscaleStatus } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { TAILSCALE_DOWNLOAD } from "@/components/settings/pair-phone-dialog"; +import { openExternal } from "@/lib/desktop"; +import { cn } from "@/lib/utils"; + +const STORAGE_KEY = "godmode-runner-network"; + +const TITLE: Record = { lan: "Same network", vm: "Virtual machine", tailscale: "Tailscale" }; + +/** The network the human picked last time; the next offer starts there. */ +export function preferredNetwork(): RunnerNetwork | null { + try { + const v = localStorage.getItem(STORAGE_KEY); + return v === "lan" || v === "vm" || v === "tailscale" ? v : null; + } catch { + return null; + } +} + +function rememberNetwork(network: RunnerNetwork) { + try { + localStorage.setItem(STORAGE_KEY, network); + } catch { + /* private mode */ + } +} + +/** The route to show: the one picked, else one on the network picked last time, else the best. */ +export function pickRoute(routes: RunnerOfferRoute[], address: string | null): RunnerOfferRoute | null { + const network = preferredNetwork(); + return routes.find((r) => r.address === address) ?? routes.find((r) => r.network === network) ?? routes[0] ?? null; +} + +/** A Tailscale address (100.64.0.0/10) or MagicDNS name. */ +export function isTailscaleHost(host: string | null | undefined): boolean { + if (!host) return false; + if (/\.ts\.net\.?$/i.test(host)) return true; + const [a, b] = host.split(".").map(Number); + return a === 100 && b! >= 64 && b! <= 127; +} + +/** Tailscale's mark: nine dots, the bottom-right ones faded. */ +export function TailscaleMark({ className }: { className?: string }) { + const dots = [ + [0, 0, 0.35], + [1, 0, 0.35], + [2, 0, 0.35], + [0, 1, 1], + [1, 1, 1], + [2, 1, 1], + [0, 2, 0.35], + [1, 2, 1], + [2, 2, 0.35], + ] as const; + return ( + + {dots.map(([x, y, o]) => ( + + ))} + + ); +} + +function NetworkIcon({ network, className }: { network: RunnerNetwork; className?: string }) { + if (network === "tailscale") return ; + const Icon = network === "vm" ? Box : Wifi; + return ; +} + +function hint(route: RunnerOfferRoute): string { + if (route.network === "tailscale") return route.detail ?? "From anywhere, through your tailnet"; + if (route.network === "vm") return `A Mac running in a VM on this one${route.detail ? ` · ${route.detail}` : ""}`; + return `Wi-Fi or Ethernet${route.detail ? ` · ${route.detail}` : ""}`; +} + +/** + * How the other Mac reaches this one: each of this computer's addresses, Tailscale with its MagicDNS name. When + * Tailscale isn't connected here, it says why and how to get it. + */ +export function NetworkPicker({ + routes, + tailscale, + value, + onChange, + onRefresh, + refreshing, +}: { + routes: RunnerOfferRoute[]; + tailscale: TailscaleStatus; + value: RunnerOfferRoute | null; + onChange: (route: RunnerOfferRoute) => void; + onRefresh: () => void; + refreshing: boolean; +}) { + const hasTailscale = routes.some((r) => r.network === "tailscale"); + return ( +
+ {routes.length > 0 && ( + { + const route = routes.find((r) => r.address === address); + if (!route) return; + rememberNetwork(route.network); + onChange(route); + }} + aria-label="Network" + className="gap-1.5" + > + {routes.map((route) => ( + + ))} + + )} + + {!hasTailscale && ( +
+ +
+

Tailscale

+

{tailscale.running ? "Tailscale has no address on this Mac yet." : tailscale.detail}

+
+
+ {!tailscale.installed && ( + + )} + +
+
+ )} + + {value?.network === "tailscale" && ( +

+ The other Mac needs Tailscale too, signed in to the same account + {tailscale.tailnet ? ( + <> + {" "} + ({tailscale.tailnet}) + + ) : null} + . Then it reaches this Mac from anywhere, not only at home. +

+ )} +
+ ); +} + +function OptionTile({ network, muted, checked }: { network: RunnerNetwork; muted?: boolean; checked?: boolean }) { + return ( + + + + ); +} + +function RouteOption({ route, checked }: { route: RunnerOfferRoute; checked: boolean }) { + const id = useId(); + return ( + + ); +} diff --git a/apps/desktop/src/components/runners/runner-card.tsx b/apps/desktop/src/components/runners/runner-card.tsx index 08f86a5f..d7269500 100644 --- a/apps/desktop/src/components/runners/runner-card.tsx +++ b/apps/desktop/src/components/runners/runner-card.tsx @@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { LiveDot } from "@/components/aicss/Motion"; import { CopyButton } from "@/components/chat/copy-button"; import { cn } from "@/lib/utils"; +import { TailscaleMark, isTailscaleHost } from "./network-picker"; import { RunnerHealthPanel, useRunnerHealth } from "./runner-health"; import { CHECK_COLOR, @@ -120,6 +121,11 @@ export function RunnerCard({ {endpoint} + {isTailscaleHost(runner.address ?? runner.addresses[0]) && ( + + Tailscale + + )}

)} diff --git a/apps/desktop/src/components/runners/runner-dialogs.tsx b/apps/desktop/src/components/runners/runner-dialogs.tsx index 298d314b..87eff0ca 100644 --- a/apps/desktop/src/components/runners/runner-dialogs.tsx +++ b/apps/desktop/src/components/runners/runner-dialogs.tsx @@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { InlineCode } from "@/components/onboarding/doctor-checklist"; +import { TailscaleMark, isTailscaleHost } from "./network-picker"; import type { RunnerActions } from "./use-runner-actions"; const MAX_NAME = 60; @@ -87,6 +88,8 @@ export function RunnerAddressesDialog({ runner, onClose, actions }: { runner: Re if (portValid && portNumber !== runner.port) patch.port = portNumber; const changed = Object.keys(patch).length > 0; const saving = actions.isBusy("update", runner.id); + const tailnet = list.filter(isTailscaleHost); + const canPreferTailscale = tailnet.length > 0 && !isTailscaleHost(list[0]); const submit = (e: FormEvent) => { e.preventDefault(); if (!changed || list.length === 0 || !portValid || saving) return; @@ -128,6 +131,23 @@ export function RunnerAddressesDialog({ runner, onClose, actions }: { runner: Re "One per line: an IP address or a name." )}

+ {canPreferTailscale && ( +
+ + + Away from home? Its Tailscale address {tailnet[0]} works from anywhere. + + +
+ )}
diff --git a/packages/core/src/remote/pairing.ts b/packages/core/src/remote/pairing.ts index 8e888a7e..478692b2 100644 --- a/packages/core/src/remote/pairing.ts +++ b/packages/core/src/remote/pairing.ts @@ -13,7 +13,8 @@ * see the command can't be opened, and nobody on the network can read the code on its way. * * The pairing listener also serves this program's own executable, so the install command can fetch the runner from - * the controller instead of the website; the command pins its SHA-256. + * the controller instead of the website; the command pins its SHA-256. There is one command per address of the + * controller (local network, virtual machines, Tailscale), so the human picks the network both computers share. */ import { createHash, hkdfSync } from "node:crypto"; import { networkInterfaces, hostname as osHostname } from "node:os"; @@ -25,7 +26,10 @@ import { parseRunnerCode, type RunnerPairingCode, type RunnerPairingOffer, + type RunnerNetwork, + type RunnerOfferRoute, type RunnerPairingOfferPayload, + type TailscaleStatus, } from "@godmode/shared"; import { deleteMeta, getMeta, setMeta } from "../db"; import { logger } from "../log"; @@ -75,6 +79,28 @@ export async function localAddresses(): Promise { return [...new Set([...lan, ...tailnet, ...mdns])]; } +interface OfferAddress { + network: RunnerNetwork; + address: string; + detail: string | null; +} + +const NETWORK_ORDER: Record = { lan: 0, vm: 1, tailscale: 2 }; + +/** Where another computer can download Godmode from while pairing: local network first, then VM bridges, then Tailscale. */ +export function offerAddresses(ts: TailscaleStatus, interfaces = networkInterfaces()): OfferAddress[] { + const out: OfferAddress[] = []; + for (const [name, list] of Object.entries(interfaces)) { + for (const a of list ?? []) { + if (a.internal || a.family !== "IPv4" || a.address.startsWith("169.254.") || out.some((o) => o.address === a.address)) continue; + if (isTailscaleIp(a.address)) out.push({ network: "tailscale", address: a.address, detail: a.address === ts.ip ? ts.dnsName : null }); + else out.push({ network: /^(bridge|vmnet|vboxnet|vnic)/.test(name) ? "vm" : "lan", address: a.address, detail: name }); + } + } + if (ts.running && ts.ip && !out.some((o) => o.address === ts.ip)) out.push({ network: "tailscale", address: ts.ip, detail: ts.dnsName }); + return out.sort((a, b) => NETWORK_ORDER[a.network] - NETWORK_ORDER[b.network]); +} + /* ------------------------------------------------------------------ */ /* Runner: pairing codes */ /* ------------------------------------------------------------------ */ @@ -170,7 +196,7 @@ export async function deliverCode(offer: RunnerPairingOfferPayload, code: string ), ); const targets = reachable.filter((u): u is string => !!u); - if (!targets.length) return { ok: false, error: "Godmode can't be reached from here." }; + if (!targets.length) return { ok: false, error: "Godmode can't be reached from here. Both computers need the same network, or Tailscale signed in to the same account." }; let lastError = "Godmode couldn't be reached."; for (const url of targets) { try { @@ -228,8 +254,8 @@ interface Offer { attempts: number; server: Server; timer: ReturnType; - /** Pairs with the code; resolves with the runner's name. */ - onCode: (code: string) => Promise<{ name: string }>; + /** Pairs with the code (`from`: the address it came from); resolves with the runner's name. */ + onCode: (code: string, from: string | null) => Promise<{ name: string }>; busy: boolean; } @@ -253,7 +279,7 @@ function json(status: number, body: unknown): Response { return Response.json(body, { status, headers: { "cache-control": "no-store" } }); } -async function handlePair(current: Offer, req: Request): Promise { +async function handlePair(current: Offer, req: Request, from: string | null): Promise { if (current.attempts >= MAX_PAIR_ATTEMPTS) return json(429, { error: "Too many attempts. Show a new install command in Godmode." }); current.attempts++; const length = Number(req.headers.get("content-length") ?? 0); @@ -279,7 +305,7 @@ async function handlePair(current: Offer, req: Request): Promise { if (current.busy) return json(409, { error: "Already pairing." }); current.busy = true; try { - const { name } = await current.onCode(code); + const { name } = await current.onCode(code, from); if (offer === current) cancelOffer(true); return json(200, { ok: true, name }); } catch (err) { @@ -301,7 +327,7 @@ function serveExecutable(): Response { * A new pairing offer (replaces the previous one) and the temporary listener the runner reaches it on. `onCode` * pairs with the code the runner sends. */ -export async function createOffer(onCode: (code: string) => Promise<{ name: string }>): Promise { +export async function createOffer(onCode: (code: string, from: string | null) => Promise<{ name: string }>): Promise { cancelOffer(); const id = newId("rpo"); const token = randomToken(32); @@ -314,11 +340,11 @@ export async function createOffer(onCode: (code: string) => Promise<{ name: stri port: 0, idleTimeout: 60, maxRequestBodySize: MAX_PAIR_BODY, - fetch: (req) => { + fetch: (req, srv) => { const current = state.current; if (!current || current !== offer || current.expiresAt <= Date.now()) return json(404, { error: "No pairing in progress" }); const path = new URL(req.url).pathname; - if (req.method === "POST" && path === "/pair") return handlePair(current, req); + if (req.method === "POST" && path === "/pair") return handlePair(current, req, srv.requestIP(req)?.address.replace(/^::ffff:/i, "") ?? null); if (req.method === "GET" && path === "/godmode") return serveExecutable(); return json(404, { error: "Not found" }); }, @@ -334,30 +360,37 @@ export async function createOffer(onCode: (code: string) => Promise<{ name: stri offer = { id, token, expiresAt, attempts: 0, server, timer, onCode, busy: false }; state.current = offer; - const addresses = (await localAddresses()).filter((a) => !a.endsWith(".local")); - const remote = addresses.map((a) => `http://${a}:${port}`); + const tailscale = await tailscaleStatus(true); + const addresses = offerAddresses(tailscale); + const remote = addresses.map((a) => `http://${a.address}:${port}`); // Last: a runner on this same computer (trying it out) reaches the offer on loopback. const urls = [...remote, `http://127.0.0.1:${port}`]; const payload: RunnerPairingOfferPayload = { v: 1, id, token, urls, name: computerName(), exp: Math.floor(expiresAt / 1000) }; const pair = encodeRunnerOffer(payload); - let command: string | null = null; + let sha: string | null = null; if (isCompiledBinary() && remote.length) { try { - const sha = await hashExecutable(); - const bin = "~/.local/bin"; - command = [ - `mkdir -p ${bin}`, - `curl -fsS ${remote[0]}/godmode -o ${bin}/godmode.new`, - `[ "$(shasum -a 256 ${bin}/godmode.new | cut -d' ' -f1)" = "${sha}" ]`, - `chmod 755 ${bin}/godmode.new`, - `mv -f ${bin}/godmode.new ${bin}/godmode`, - `${bin}/godmode runner install --pair ${pair}`, - ].join(" && "); + sha = await hashExecutable(); } catch (err) { log.warn("couldn't hash this program for the install command", err); } } + const bin = "~/.local/bin"; + const routes: RunnerOfferRoute[] = addresses.map((a, i) => ({ + ...a, + url: remote[i]!, + command: sha + ? [ + `mkdir -p ${bin}`, + `curl -fsS ${remote[i]}/godmode -o ${bin}/godmode.new`, + `[ "$(shasum -a 256 ${bin}/godmode.new | cut -d' ' -f1)" = "${sha}" ]`, + `chmod 755 ${bin}/godmode.new`, + `mv -f ${bin}/godmode.new ${bin}/godmode`, + `${bin}/godmode runner install --pair ${pair}`, + ].join(" && ") + : null, + })); const websiteCommand = `curl -fsSL https://godmode.codext.de/runner.sh | GODMODE_LICENSE=GM-XXXXX-XXXXX-XXXXX-XXXXX GODMODE_PAIR=${pair} sh`; - return { offerId: id, command, websiteCommand, urls, expiresAt: new Date(expiresAt).toISOString() }; + return { offerId: id, command: routes[0]?.command ?? null, routes, websiteCommand, urls, tailscale, expiresAt: new Date(expiresAt).toISOString() }; } diff --git a/packages/core/src/remote/runners.ts b/packages/core/src/remote/runners.ts index 49b5a5d0..a1189b50 100644 --- a/packages/core/src/remote/runners.ts +++ b/packages/core/src/remote/runners.ts @@ -247,8 +247,11 @@ export async function removeRunner(id: string): Promise { /* Pairing */ /* ------------------------------------------------------------------ */ -/** Pair with a runner from its pairing code (pasted, or delivered by `godmode runner install --pair`). */ -export async function pairWithCode(text: string): Promise { +/** + * Pair with a runner from its pairing code (pasted, or delivered by `godmode runner install --pair`). `via`: the + * address the code was delivered from, dialed first (it proved to reach this computer, e.g. over Tailscale). + */ +export async function pairWithCode(text: string, via: string | null = null): Promise { const code = parseRunnerCode(text); if (!code) throw badRequest("That isn't a runner's pairing code. It starts with gmr1."); let key: string; @@ -257,6 +260,7 @@ export async function pairWithCode(text: string): Promise { } catch { throw badRequest("That pairing code is damaged. Copy it again."); } + if (via && code.addresses.includes(via)) code.addresses = [via, ...code.addresses.filter((a) => a !== via)]; const { info, address } = await RemoteLink.pair({ identity: loadIdentity(), code, name: computerName() }); const addresses = [...new Set([address, ...code.addresses])].slice(0, MAX_ADDRESSES); const ts = now(); @@ -300,7 +304,7 @@ export async function pairWithCode(text: string): Promise { /** Show an install command that pairs the runner by itself. */ export function createPairing(): Promise { - return createOffer(async (code) => ({ name: (await pairWithCode(code)).name })); + return createOffer(async (code, from) => ({ name: (await pairWithCode(code, from)).name })); } export function cancelPairing(): void { diff --git a/packages/core/test/remote-link.test.ts b/packages/core/test/remote-link.test.ts index e9107a29..ad6802f1 100644 --- a/packages/core/test/remote-link.test.ts +++ b/packages/core/test/remote-link.test.ts @@ -11,7 +11,8 @@ import { setLogLevel } from "../src/log"; import { generateKeyPair } from "../src/remote/crypto"; import { RemoteLink, type LinkState } from "../src/remote/linkClient"; import { forgetController, startLinkServer, stopLinkServer } from "../src/remote/linkServer"; -import { cancelOffer, createOffer, createRunnerCode, deliverCode } from "../src/remote/pairing"; +import { setTailscaleOverride } from "../src/mobile/tailscale"; +import { cancelOffer, createOffer, createRunnerCode, deliverCode, offerAddresses } from "../src/remote/pairing"; import { getAccessToken } from "../src/server/auth"; import { websocketHandler } from "../src/server/ws"; import { HttpError, sleep } from "../src/util"; @@ -238,6 +239,43 @@ describe("pairing offers", () => { expect(received).toHaveLength(1); }); + test("the offer lists this computer's networks, Tailscale with its MagicDNS name, and the code arrives through any of them", async () => { + const ts = { installed: true, running: true, ip: "100.101.102.103", dnsName: "studio.tail1234.ts.net", tailnet: "me@example.com", detail: null }; + const nic = (address: string, internal = false) => ({ address, family: "IPv4" as const, internal, netmask: "255.255.255.0", mac: "00:00:00:00:00:00", cidr: null }); + const routes = offerAddresses(ts, { + lo0: [nic("127.0.0.1", true)], + utun4: [nic("100.101.102.103")], + bridge100: [nic("192.168.64.1")], + en0: [nic("192.168.68.56"), nic("169.254.10.2")], + }); + expect(routes).toEqual([ + { network: "lan", address: "192.168.68.56", detail: "en0" }, + { network: "vm", address: "192.168.64.1", detail: "bridge100" }, + { network: "tailscale", address: "100.101.102.103", detail: "studio.tail1234.ts.net" }, + ]); + expect(offerAddresses(ts, { en0: [nic("10.0.0.4")] }).at(-1)).toEqual({ network: "tailscale", address: "100.101.102.103", detail: "studio.tail1234.ts.net" }); + expect(offerAddresses({ ...ts, running: false, ip: null }, { en0: [nic("10.0.0.4")] })).toHaveLength(1); + + setTailscaleOverride(ts); + try { + const from: (string | null)[] = []; + const offer = await createOffer(async (_c, via) => { + from.push(via); + return { name: "Controller" }; + }); + expect(offer.tailscale.dnsName).toBe("studio.tail1234.ts.net"); + expect(offer.routes.at(-1)).toMatchObject({ network: "tailscale", address: "100.101.102.103", detail: "studio.tail1234.ts.net" }); + expect(offer.routes.map((r) => r.url)).toEqual(offer.urls.slice(0, offer.routes.length)); + const payload = parseRunnerOffer(/GODMODE_PAIR=(\S+)/.exec(offer.websiteCommand)![1]!)!; + const { code: runnerCode } = await createRunnerCode(); + expect(await deliverCode({ ...payload, urls: offer.urls.filter((u) => u.startsWith("http://127.0.0.1:")) }, runnerCode)).toEqual({ ok: true, name: "Controller" }); + expect(from).toEqual(["127.0.0.1"]); + } finally { + setTailscaleOverride(null); + cancelOffer(); + } + }); + test("an expired offer is not delivered", async () => { const offer = await createOffer(async () => ({ name: "Controller" })); const payload = parseRunnerOffer(/GODMODE_PAIR=(\S+)/.exec(offer.websiteCommand)![1]!)!; diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index d24d105b..77d3a55f 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -3,6 +3,7 @@ * computer sleeps. The Godmode the human uses (the controller) pairs with a runner once, copies what the work needs to * 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"; /** Port the runner listens on for the encrypted link (phones use 7787). */ @@ -66,15 +67,34 @@ export interface RunnerPatch { syncBrowser?: boolean; } +/** How a runner reaches this computer: the local network, a virtual machine's bridge, or Tailscale. */ +export type RunnerNetwork = "lan" | "vm" | "tailscale"; + +/** One address of this computer the install command can use. */ +export interface RunnerOfferRoute { + network: RunnerNetwork; + address: string; + /** `http://
:` of the pairing listener. */ + url: string; + /** The interface ("en0"), or Tailscale's MagicDNS name of this computer. */ + detail: string | null; + /** The install command through this address; null when this build can't serve its own binary. */ + command: string | null; +} + /** POST /api/runners/pairing */ export interface RunnerPairingOffer { offerId: ID; - /** One command that installs the runner from this computer and pairs it; null when this build can't serve its own binary. */ + /** One command that installs the runner from this computer and pairs it (through the first route); null when this build can't serve its own binary. */ command: string | null; + /** Every address of this computer the other Mac can download from, best first. */ + routes: RunnerOfferRoute[]; /** The same through godmode.codext.de (needs the license key in place of the placeholder). */ websiteCommand: string; /** Where the runner reaches this computer while pairing. */ urls: string[]; + /** Tailscale on this computer, so the human can pick it or learn why it isn't there. */ + tailscale: TailscaleStatus; expiresAt: ISODate; }