talon-agent 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -58,11 +58,23 @@ export const meshHandlers: SharedActionHandlers = {
58
58
  body.remote_path,
59
59
  ),
60
60
  // Remote self-update for a headless talon-node: push a new binary and have
61
- // the node verify, swap, and restart into it.
61
+ // the node verify, swap, and restart into it. binary_path is optional —
62
+ // omitted, the daemon resolves the right build for the node's
63
+ // platform/arch itself (source build or verified release download).
62
64
  update_node: (body) =>
63
65
  getMeshService().updateNodeBinary(
64
66
  body.device,
65
67
  body.binary_path,
66
68
  body.remote_path,
67
69
  ),
70
+ // Node provisioning: materialize a talon-node binary for any arch, and
71
+ // mint single-use bridge-served install links for fresh hosts.
72
+ get_node_binary: (body) => getMeshService().getNodeBinary(body.os, body.arch),
73
+ make_node_install_link: (body) =>
74
+ getMeshService().makeNodeInstallLink(
75
+ body.os,
76
+ body.arch,
77
+ body.name,
78
+ body.bridge_url,
79
+ ),
68
80
  };
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Node-binary resolver — materializes a talon-node binary for any supported
3
+ * platform/arch, whatever shape this daemon was installed in.
4
+ *
5
+ * The old flow required a source checkout plus a Go toolchain (`update_node`
6
+ * only took a path the caller had built). This resolver keeps that path and
7
+ * adds two more, tried in order:
8
+ *
9
+ * 1. source build — running from a git checkout with Go on PATH: cross-
10
+ * compile apps/node for the requested target (always rebuilt, so a dev
11
+ * daemon ships its working-tree code, stamped <version>+<sha>).
12
+ * 2. cache — ~/.talon/node-bin/<talon-version>/talon-node-<os>-<arch>,
13
+ * re-verified against its recorded digest on every hit.
14
+ * 3. release download — the GitHub release matching THIS daemon's version
15
+ * publishes the full binary matrix plus a talon-node-SHA256SUMS
16
+ * manifest (built by .github/workflows/node.yml); fetch, verify the
17
+ * digest, and cache. This is what makes prebuilt installs (npm, deb,
18
+ * standalone binary) able to provision nodes at all.
19
+ *
20
+ * Version lock is structural: cache keys and release URLs both derive from
21
+ * `talonVersion()`, so a resolved binary always matches the daemon exactly.
22
+ */
23
+
24
+ import { execFile } from "node:child_process";
25
+ import { createHash } from "node:crypto";
26
+ import {
27
+ chmod,
28
+ mkdir,
29
+ readdir,
30
+ readFile,
31
+ rename,
32
+ rm,
33
+ stat,
34
+ writeFile,
35
+ } from "node:fs/promises";
36
+ import { join, resolve } from "node:path";
37
+ import { TalonError } from "../errors.js";
38
+ import { getRepoRoot } from "../update/self-update.js";
39
+ import { dirs } from "../../util/paths.js";
40
+ import { talonVersion } from "../../util/version.js";
41
+ import type { DevicePlatform } from "./types.js";
42
+
43
+ /** A cross-compile target, named exactly as Go (and the release assets) do. */
44
+ export type NodeTarget = {
45
+ goos: "linux" | "darwin" | "windows";
46
+ goarch: string;
47
+ };
48
+
49
+ /** The supported matrix — mirrors apps/node/tools/build/main.go. */
50
+ export const NODE_TARGETS: readonly NodeTarget[] = [
51
+ { goos: "linux", goarch: "amd64" },
52
+ { goos: "linux", goarch: "arm64" },
53
+ { goos: "linux", goarch: "arm" },
54
+ { goos: "darwin", goarch: "amd64" },
55
+ { goos: "darwin", goarch: "arm64" },
56
+ { goos: "windows", goarch: "amd64" },
57
+ ];
58
+
59
+ const SUMS_ASSET = "talon-node-SHA256SUMS";
60
+ const RELEASE_BASE = "https://github.com/dylanneve1/talon/releases/download";
61
+ const DOWNLOAD_TIMEOUT_MS = 120_000;
62
+ const BUILD_TIMEOUT_MS = 300_000;
63
+
64
+ export type ResolvedNodeBinary = {
65
+ path: string;
66
+ /** Full stamp the binary reports (release: the semver; build: semver+sha). */
67
+ version: string;
68
+ sha256: string;
69
+ size: number;
70
+ /** Which tier produced it. */
71
+ source: "source-build" | "cache" | "release";
72
+ };
73
+
74
+ export type ResolveNodeBinaryOptions = {
75
+ /** Cache root (default ~/.talon/node-bin). */
76
+ cacheRoot?: string;
77
+ /** Source checkout root; null disables the build tier (default: detect). */
78
+ repoRoot?: string | null;
79
+ /** Release download (test seam). */
80
+ fetchImpl?: typeof fetch;
81
+ /** Daemon version override (test seam). */
82
+ version?: string;
83
+ };
84
+
85
+ /** The resolver's function shape — the seam MeshService accepts in tests. */
86
+ export type NodeBinaryResolver = (
87
+ goos: string,
88
+ goarch: string,
89
+ opts?: ResolveNodeBinaryOptions,
90
+ ) => Promise<ResolvedNodeBinary>;
91
+
92
+ /** Release asset / on-disk name for one target. */
93
+ export function nodeAssetName(goos: string, goarch: string): string {
94
+ return `talon-node-${goos}-${goarch}${goos === "windows" ? ".exe" : ""}`;
95
+ }
96
+
97
+ /**
98
+ * Normalize a caller-supplied OS ("macos", "darwin", "Linux") to a matrix
99
+ * GOOS, or undefined for anything outside it.
100
+ */
101
+ export function normalizeGoos(value: unknown): NodeTarget["goos"] | undefined {
102
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "";
103
+ if (v === "macos" || v === "darwin" || v === "osx") return "darwin";
104
+ if (v === "linux") return "linux";
105
+ if (v === "windows" || v === "win32") return "windows";
106
+ return undefined;
107
+ }
108
+
109
+ /** Normalize a caller-supplied arch ("x86_64", "aarch64") to a Go arch. */
110
+ export function normalizeGoarch(value: unknown): string | undefined {
111
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "";
112
+ if (v === "amd64" || v === "x86_64" || v === "x64") return "amd64";
113
+ if (v === "arm64" || v === "aarch64") return "arm64";
114
+ if (v === "arm" || v === "armv7" || v === "armv7l") return "arm";
115
+ return undefined;
116
+ }
117
+
118
+ /** Map a mesh platform onto a node GOOS (mobile platforms have no node). */
119
+ export function platformToGoos(
120
+ platform: DevicePlatform,
121
+ ): NodeTarget["goos"] | undefined {
122
+ return normalizeGoos(platform === "macos" ? "darwin" : platform);
123
+ }
124
+
125
+ function isSupported(goos: string, goarch: string): boolean {
126
+ return NODE_TARGETS.some((t) => t.goos === goos && t.goarch === goarch);
127
+ }
128
+
129
+ /**
130
+ * Resolve a node binary for `goos`/`goarch`. Throws with an operator-facing
131
+ * message when every tier fails — callers surface it verbatim.
132
+ */
133
+ export async function resolveNodeBinary(
134
+ goos: string,
135
+ goarch: string,
136
+ opts: ResolveNodeBinaryOptions = {},
137
+ ): Promise<ResolvedNodeBinary> {
138
+ if (!isSupported(goos, goarch)) {
139
+ throw new TalonError(
140
+ `No talon-node target for ${goos}/${goarch}. Supported: ${NODE_TARGETS.map(
141
+ (t) => `${t.goos}/${t.goarch}`,
142
+ ).join(", ")}.`,
143
+ { reason: "bad_request" },
144
+ );
145
+ }
146
+ const version = opts.version ?? talonVersion();
147
+ const cacheRoot = opts.cacheRoot ?? resolve(dirs.root, "node-bin");
148
+ const repoRoot = opts.repoRoot === undefined ? getRepoRoot() : opts.repoRoot;
149
+
150
+ const errors: string[] = [];
151
+
152
+ if (repoRoot) {
153
+ try {
154
+ return await buildFromSource(repoRoot, goos, goarch, version, cacheRoot);
155
+ } catch (err) {
156
+ errors.push(`source build: ${(err as Error).message}`);
157
+ }
158
+ }
159
+
160
+ const cached = await fromCache(cacheRoot, version, goos, goarch);
161
+ if (cached) return cached;
162
+
163
+ try {
164
+ return await downloadFromRelease(
165
+ version,
166
+ goos,
167
+ goarch,
168
+ cacheRoot,
169
+ opts.fetchImpl ?? fetch,
170
+ );
171
+ } catch (err) {
172
+ errors.push(`release download: ${(err as Error).message}`);
173
+ }
174
+
175
+ throw new TalonError(
176
+ `Could not obtain talon-node ${goos}/${goarch} for Talon ${version} — ${errors.join("; ")}`,
177
+ { reason: "unknown" },
178
+ );
179
+ }
180
+
181
+ // ── Tier 1: source build ────────────────────────────────────────────────────
182
+
183
+ async function buildFromSource(
184
+ repoRoot: string,
185
+ goos: string,
186
+ goarch: string,
187
+ version: string,
188
+ cacheRoot: string,
189
+ ): Promise<ResolvedNodeBinary> {
190
+ const nodeDir = join(repoRoot, "apps", "node");
191
+ await stat(join(nodeDir, "go.mod"));
192
+ // Dev builds land under their own key: they track the working tree, not a
193
+ // release, and are rebuilt on every resolve so they can never go stale.
194
+ const outDir = join(cacheRoot, "dev");
195
+ await mkdir(outDir, { recursive: true });
196
+ const dest = join(outDir, nodeAssetName(goos, goarch));
197
+ const stamp = `${version}+${await gitShortSha(repoRoot)}`;
198
+ await run(
199
+ "go",
200
+ [
201
+ "build",
202
+ "-trimpath",
203
+ "-ldflags",
204
+ `-s -w -X main.ldflagsVersion=${stamp}`,
205
+ "-o",
206
+ dest,
207
+ ".",
208
+ ],
209
+ nodeDir,
210
+ { CGO_ENABLED: "0", GOOS: goos, GOARCH: goarch },
211
+ BUILD_TIMEOUT_MS,
212
+ );
213
+ const { sha256, size } = await hashFile(dest);
214
+ return { path: dest, version: stamp, sha256, size, source: "source-build" };
215
+ }
216
+
217
+ async function gitShortSha(repoRoot: string): Promise<string> {
218
+ try {
219
+ return (
220
+ await run("git", ["rev-parse", "--short", "HEAD"], repoRoot, {}, 10_000)
221
+ ).trim();
222
+ } catch {
223
+ return "dev";
224
+ }
225
+ }
226
+
227
+ function run(
228
+ cmd: string,
229
+ args: string[],
230
+ cwd: string,
231
+ env: Record<string, string>,
232
+ timeoutMs: number,
233
+ ): Promise<string> {
234
+ return new Promise((res, rej) => {
235
+ execFile(
236
+ cmd,
237
+ args,
238
+ { cwd, env: { ...process.env, ...env }, timeout: timeoutMs },
239
+ (err, stdout, stderr) => {
240
+ if (err) {
241
+ rej(
242
+ new Error(
243
+ `${cmd} ${args[0]} failed: ${String(stderr || err.message)
244
+ .trim()
245
+ .slice(0, 500)}`,
246
+ ),
247
+ );
248
+ } else {
249
+ res(String(stdout));
250
+ }
251
+ },
252
+ );
253
+ });
254
+ }
255
+
256
+ // ── Tier 2: cache ───────────────────────────────────────────────────────────
257
+
258
+ async function fromCache(
259
+ cacheRoot: string,
260
+ version: string,
261
+ goos: string,
262
+ goarch: string,
263
+ ): Promise<ResolvedNodeBinary | null> {
264
+ const path = join(cacheRoot, version, nodeAssetName(goos, goarch));
265
+ let recorded: string;
266
+ try {
267
+ recorded = (await readFile(`${path}.sha256`, "utf8")).trim();
268
+ } catch {
269
+ return null;
270
+ }
271
+ try {
272
+ const { sha256, size } = await hashFile(path);
273
+ if (size > 0 && sha256 === recorded) {
274
+ return { path, version, sha256, size, source: "cache" };
275
+ }
276
+ } catch {
277
+ // Fall through — a corrupt entry is re-downloaded, not fatal.
278
+ }
279
+ await rm(path, { force: true }).catch(() => {});
280
+ await rm(`${path}.sha256`, { force: true }).catch(() => {});
281
+ return null;
282
+ }
283
+
284
+ // ── Tier 3: release download ────────────────────────────────────────────────
285
+
286
+ async function downloadFromRelease(
287
+ version: string,
288
+ goos: string,
289
+ goarch: string,
290
+ cacheRoot: string,
291
+ fetchImpl: typeof fetch,
292
+ ): Promise<ResolvedNodeBinary> {
293
+ const asset = nodeAssetName(goos, goarch);
294
+ const base = `${RELEASE_BASE}/v${version}`;
295
+ const sums = parseSums(await fetchText(fetchImpl, `${base}/${SUMS_ASSET}`));
296
+ const expected = sums.get(asset);
297
+ if (!expected) {
298
+ throw new TalonError(
299
+ `${SUMS_ASSET} for v${version} has no entry for ${asset}`,
300
+ { reason: "bad_request" },
301
+ );
302
+ }
303
+ const body = await fetchBytes(fetchImpl, `${base}/${asset}`);
304
+ const sha256 = createHash("sha256").update(body).digest("hex");
305
+ if (sha256 !== expected) {
306
+ throw new TalonError(
307
+ `digest mismatch for ${asset} (expected ${expected.slice(0, 12)}…, got ${sha256.slice(0, 12)}…) — refusing to cache`,
308
+ { reason: "unknown" },
309
+ );
310
+ }
311
+ const dir = join(cacheRoot, version);
312
+ await mkdir(dir, { recursive: true });
313
+ const dest = join(dir, asset);
314
+ const tmp = `${dest}.tmp-${process.pid}`;
315
+ await writeFile(tmp, body);
316
+ await chmod(tmp, 0o755);
317
+ await rename(tmp, dest);
318
+ await writeFile(`${dest}.sha256`, `${sha256}\n`);
319
+ await pruneStaleVersions(cacheRoot, version);
320
+ return { path: dest, version, sha256, size: body.length, source: "release" };
321
+ }
322
+
323
+ /** Parse "<hex> <name>" manifest lines (the `sha256sum` format). */
324
+ function parseSums(text: string): Map<string, string> {
325
+ const out = new Map<string, string>();
326
+ for (const line of text.split("\n")) {
327
+ const m = /^([0-9a-f]{64})\s+\*?(\S+)\s*$/.exec(line.trim());
328
+ if (m) out.set(m[2]!, m[1]!);
329
+ }
330
+ return out;
331
+ }
332
+
333
+ async function fetchText(
334
+ fetchImpl: typeof fetch,
335
+ url: string,
336
+ ): Promise<string> {
337
+ const res = await fetchImpl(url, {
338
+ redirect: "follow",
339
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
340
+ });
341
+ if (!res.ok) {
342
+ throw new TalonError(`GET ${url}: HTTP ${res.status}`, {
343
+ reason: "network",
344
+ retryable: true,
345
+ status: res.status,
346
+ });
347
+ }
348
+ return res.text();
349
+ }
350
+
351
+ async function fetchBytes(
352
+ fetchImpl: typeof fetch,
353
+ url: string,
354
+ ): Promise<Buffer> {
355
+ const res = await fetchImpl(url, {
356
+ redirect: "follow",
357
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
358
+ });
359
+ if (!res.ok) {
360
+ throw new TalonError(`GET ${url}: HTTP ${res.status}`, {
361
+ reason: "network",
362
+ retryable: true,
363
+ status: res.status,
364
+ });
365
+ }
366
+ return Buffer.from(await res.arrayBuffer());
367
+ }
368
+
369
+ /** Drop cached matrices for other Talon versions — they can never be used
370
+ * again (the resolver only serves version-exact binaries). Best-effort. */
371
+ async function pruneStaleVersions(
372
+ cacheRoot: string,
373
+ keep: string,
374
+ ): Promise<void> {
375
+ try {
376
+ for (const entry of await readdir(cacheRoot)) {
377
+ if (entry === keep || entry === "dev") continue;
378
+ await rm(join(cacheRoot, entry), { recursive: true, force: true });
379
+ }
380
+ } catch {
381
+ // Cache hygiene only — never fail a resolve over it.
382
+ }
383
+ }
384
+
385
+ async function hashFile(
386
+ path: string,
387
+ ): Promise<{ sha256: string; size: number }> {
388
+ const data = await readFile(path);
389
+ return {
390
+ sha256: createHash("sha256").update(data).digest("hex"),
391
+ size: data.length,
392
+ };
393
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * NodeProvisionStore — one-time grants that turn a fresh host into a mesh
3
+ * node with a single command.
4
+ *
5
+ * The daemon mints a grant bound to one resolved binary (path + digest) and
6
+ * one target platform; the bridge serves two UNAUTHENTICATED but
7
+ * token-gated routes against it:
8
+ *
9
+ * GET /node/install?provision=<token> → installer script (once)
10
+ * GET /node/binary?provision=<token> → the binary itself (once)
11
+ *
12
+ * The routes must work pre-auth — the whole point is a host that holds no
13
+ * bridge credential yet — so the grant token IS the authorization, exactly
14
+ * like streamed-transfer tokens (transfers.ts): random 192-bit, single-use
15
+ * per leg, expiring unused. The script carries the bridge bearer token and
16
+ * pinned TLS fingerprint into the node's config, verifies the downloaded
17
+ * binary against the grant's digest (integrity is the script's own sha256
18
+ * check, not TLS), installs it, and registers the boot service via
19
+ * `talon-node install`.
20
+ */
21
+
22
+ import { randomBytes } from "node:crypto";
23
+
24
+ /** Unclaimed grants die after this long. */
25
+ const GRANT_TTL_MS = 30 * 60 * 1000;
26
+
27
+ export type NodeProvisionGrant = {
28
+ token: string;
29
+ goos: "linux" | "darwin" | "windows";
30
+ goarch: string;
31
+ /** Device name baked into the installer (optional — defaults to hostname). */
32
+ name?: string;
33
+ /** The resolved binary this grant serves. */
34
+ binaryPath: string;
35
+ sha256: string;
36
+ size: number;
37
+ version: string;
38
+ /** Bridge base URL as reachable from the target host. */
39
+ bridgeUrl: string;
40
+ /** Bridge bearer token the node will authenticate with. */
41
+ bearerToken: string;
42
+ /** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
43
+ fingerprint?: string;
44
+ createdAt: number;
45
+ scriptUsed: boolean;
46
+ binaryUsed: boolean;
47
+ };
48
+
49
+ export class NodeProvisionStore {
50
+ private readonly grants = new Map<string, NodeProvisionGrant>();
51
+
52
+ constructor(private readonly ttlMs = GRANT_TTL_MS) {}
53
+
54
+ create(
55
+ grant: Omit<
56
+ NodeProvisionGrant,
57
+ "token" | "createdAt" | "scriptUsed" | "binaryUsed"
58
+ >,
59
+ ): NodeProvisionGrant {
60
+ this.sweep();
61
+ const full: NodeProvisionGrant = {
62
+ ...grant,
63
+ // Device names are injected into generated shell/PowerShell text —
64
+ // keep them to characters that can't break out of a quoted string.
65
+ ...(grant.name
66
+ ? { name: grant.name.replace(/[^\w .-]+/g, "").slice(0, 64) }
67
+ : {}),
68
+ token: randomBytes(24).toString("base64url"),
69
+ createdAt: Date.now(),
70
+ scriptUsed: false,
71
+ binaryUsed: false,
72
+ };
73
+ this.grants.set(full.token, full);
74
+ return full;
75
+ }
76
+
77
+ /** Serve the installer script for a live grant — once. */
78
+ openScript(token: string): { script: string; filename: string } | null {
79
+ const grant = this.claim(token, "scriptUsed");
80
+ if (!grant) return null;
81
+ return grant.goos === "windows"
82
+ ? {
83
+ script: powershellInstaller(grant),
84
+ filename: "install-talon-node.ps1",
85
+ }
86
+ : { script: shellInstaller(grant), filename: "install-talon-node.sh" };
87
+ }
88
+
89
+ /** Resolve the binary leg of a live grant — once. */
90
+ openBinary(token: string): { path: string; size: number } | null {
91
+ const grant = this.claim(token, "binaryUsed");
92
+ if (!grant) return null;
93
+ return { path: grant.binaryPath, size: grant.size };
94
+ }
95
+
96
+ /** Expire-check + single-use latch for one leg of a grant. */
97
+ private claim(
98
+ token: string,
99
+ leg: "scriptUsed" | "binaryUsed",
100
+ ): NodeProvisionGrant | null {
101
+ this.sweep();
102
+ const grant = this.grants.get(token);
103
+ if (!grant || grant[leg]) return null;
104
+ grant[leg] = true;
105
+ if (grant.scriptUsed && grant.binaryUsed) this.grants.delete(token);
106
+ return grant;
107
+ }
108
+
109
+ private sweep(): void {
110
+ const cutoff = Date.now() - this.ttlMs;
111
+ for (const [token, grant] of this.grants) {
112
+ if (grant.createdAt < cutoff) this.grants.delete(token);
113
+ }
114
+ }
115
+ }
116
+
117
+ /** The command a human (or the model over SSH) runs on the target host. */
118
+ export function installOneLiner(grant: NodeProvisionGrant): string {
119
+ const url = `${grant.bridgeUrl}/node/install?provision=${grant.token}`;
120
+ if (grant.goos === "windows") {
121
+ // -k has no iwr flag; trust-all callback covers the self-signed bridge
122
+ // cert for the two fetches — the binary itself is digest-verified. The
123
+ // one-liner is meant for cmd.exe / PowerShell, where $true survives the
124
+ // outer double quotes verbatim.
125
+ return (
126
+ `powershell -ExecutionPolicy Bypass -Command "` +
127
+ `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
128
+ `iwr -UseBasicParsing '${url}' | Select-Object -ExpandProperty Content | iex"`
129
+ );
130
+ }
131
+ return `curl -fsSk "${url}" | sh`;
132
+ }
133
+
134
+ /**
135
+ * POSIX installer: fetch the binary over the same grant, verify its digest,
136
+ * install it next to the node's config, and register the boot service. -k on
137
+ * the fetches is deliberate — the bridge cert is self-signed; integrity
138
+ * comes from the embedded sha256 and the fingerprint is pre-pinned into the
139
+ * node's config for every connection after install.
140
+ */
141
+ function shellInstaller(grant: NodeProvisionGrant): string {
142
+ const nameFlag = grant.name ? ` --name "${grant.name}"` : "";
143
+ const fpFlag = grant.fingerprint
144
+ ? ` --fingerprint "${grant.fingerprint}"`
145
+ : "";
146
+ return `#!/bin/sh
147
+ # talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
148
+ set -eu
149
+ BRIDGE="${grant.bridgeUrl}"
150
+ SHA="${grant.sha256}"
151
+ if [ "$(id -u)" = "0" ]; then DEST_DIR="\${TALON_NODE_DIR:-/usr/local/bin}"; else DEST_DIR="\${TALON_NODE_DIR:-$HOME/.talon-node/bin}"; fi
152
+ mkdir -p "$DEST_DIR"
153
+ TMP="$(mktemp)"
154
+ trap 'rm -f "$TMP"' EXIT
155
+ echo "Downloading talon-node ${grant.version} (${grant.goos}/${grant.goarch})..."
156
+ curl -fsSk "$BRIDGE/node/binary?provision=${grant.token}" -o "$TMP"
157
+ if command -v sha256sum >/dev/null 2>&1; then GOT="$(sha256sum "$TMP" | cut -d' ' -f1)"; else GOT="$(shasum -a 256 "$TMP" | cut -d' ' -f1)"; fi
158
+ if [ "$GOT" != "$SHA" ]; then echo "talon-node download failed its checksum — refusing to install" >&2; exit 1; fi
159
+ BIN="$DEST_DIR/talon-node"
160
+ mv "$TMP" "$BIN"
161
+ chmod 0755 "$BIN"
162
+ trap - EXIT
163
+ echo "Installed $BIN"
164
+ "$BIN" install --bridge "$BRIDGE" --token "${grant.bearerToken}"${fpFlag}${nameFlag}
165
+ echo "Done — this host is now on the mesh. Check with: $BIN status"
166
+ `;
167
+ }
168
+
169
+ /** PowerShell installer (Windows PowerShell 5+ compatible). */
170
+ function powershellInstaller(grant: NodeProvisionGrant): string {
171
+ const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
172
+ const fpArg = grant.fingerprint
173
+ ? `, "--fingerprint", "${grant.fingerprint}"`
174
+ : "";
175
+ return `# talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
176
+ $ErrorActionPreference = "Stop"
177
+ [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
178
+ $bridge = "${grant.bridgeUrl}"
179
+ $dest = Join-Path $env:LOCALAPPDATA "talon-node"
180
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
181
+ $bin = Join-Path $dest "talon-node.exe"
182
+ Write-Host "Downloading talon-node ${grant.version} (${grant.goos}/${grant.goarch})..."
183
+ Invoke-WebRequest -UseBasicParsing "$bridge/node/binary?provision=${grant.token}" -OutFile $bin
184
+ $got = (Get-FileHash $bin -Algorithm SHA256).Hash.ToLower()
185
+ if ($got -ne "${grant.sha256}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
186
+ Write-Host "Installed $bin"
187
+ & $bin install --bridge $bridge --token "${grant.bearerToken}"${fpArg}${nameArg}
188
+ Write-Host "Done - this host is now on the mesh. Check with: $bin status"
189
+ `;
190
+ }
@@ -237,10 +237,12 @@ function sanitizeDevice(
237
237
  ) {
238
238
  return null;
239
239
  }
240
+ const arch = stringField(body.arch)?.toLowerCase().slice(0, 16);
240
241
  return {
241
242
  id: id ?? "",
242
243
  name: name ?? "",
243
244
  platform: platform ?? "linux",
245
+ ...(arch ? { arch } : {}),
244
246
  appVersion: appVersion ?? "",
245
247
  online: body.online === true,
246
248
  lastSeen: numberField(body.lastSeen) ?? now,
@@ -30,11 +30,20 @@ import { randomUUID } from "node:crypto";
30
30
  import { createHash } from "node:crypto";
31
31
  import { createReadStream } from "node:fs";
32
32
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
33
- import { tmpdir } from "node:os";
33
+ import { networkInterfaces, tmpdir } from "node:os";
34
34
  import { basename, dirname, join, resolve } from "node:path";
35
35
  import type { Readable } from "node:stream";
36
36
  import { clampExecOutput } from "../../util/exec-output.js";
37
37
  import { dirs } from "../../util/paths.js";
38
+ import {
39
+ NODE_TARGETS,
40
+ normalizeGoarch,
41
+ normalizeGoos,
42
+ platformToGoos,
43
+ resolveNodeBinary,
44
+ type NodeBinaryResolver,
45
+ } from "./node-binaries.js";
46
+ import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
38
47
  import { MeshRegistry } from "./registry.js";
39
48
  import { TransferStore } from "./transfers.js";
40
49
  import type {
@@ -72,6 +81,25 @@ export type MeshServiceOptions = {
72
81
  pollIntervalMs?: number;
73
82
  /** How long a device command waits for its result before timing out. */
74
83
  commandTimeoutMs?: number;
84
+ /** Node-binary resolver override (tests — the real one builds/downloads). */
85
+ nodeBinaryResolver?: NodeBinaryResolver;
86
+ };
87
+
88
+ /**
89
+ * What the native bridge tells the mesh about itself once it's listening —
90
+ * everything a generated node installer needs to point a fresh host here.
91
+ * Registered by the native frontend after server start; null when the
92
+ * bridge isn't running (provisioning tools then fail with a clear reason).
93
+ */
94
+ export type MeshBridgeInfo = {
95
+ scheme: "http" | "https";
96
+ /** The bind host from config — may be a wildcard (0.0.0.0/::). */
97
+ host: string;
98
+ port: number;
99
+ /** Bearer token clients authenticate with (absent on open loopback). */
100
+ token?: string;
101
+ /** TLS certificate SHA-256 (absent over plain HTTP). */
102
+ fingerprint?: string;
75
103
  };
76
104
 
77
105
  const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
@@ -144,6 +172,10 @@ export class MeshService {
144
172
  private readonly receivedAt = new Map<string, number>();
145
173
  /** One-time tokens arranging streamed (single-HTTP-request) transfers. */
146
174
  private readonly transfers = new TransferStore();
175
+ /** One-time grants for bridge-served node installers. */
176
+ private readonly provision = new NodeProvisionStore();
177
+ private readonly resolveNode: NodeBinaryResolver;
178
+ private bridgeInfo: MeshBridgeInfo | null = null;
147
179
  private readonly freshFixTimeoutMs: number;
148
180
  private readonly pollIntervalMs: number;
149
181
  private readonly commandTimeoutMs: number;
@@ -158,6 +190,12 @@ export class MeshService {
158
190
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
159
191
  this.commandTimeoutMs =
160
192
  options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
193
+ this.resolveNode = options.nodeBinaryResolver ?? resolveNodeBinary;
194
+ }
195
+
196
+ /** The native bridge reports its reachable identity here (null on stop). */
197
+ setBridgeInfo(info: MeshBridgeInfo | null): void {
198
+ this.bridgeInfo = info;
161
199
  }
162
200
 
163
201
  /** Hydrate persisted devices/locations. Idempotent — safe to await from
@@ -923,19 +961,17 @@ export class MeshService {
923
961
  *
924
962
  * The binary is hashed here and the digest travels with the command; the
925
963
  * node re-hashes the pushed file and refuses to swap on a mismatch, so a
926
- * truncated transfer can never be installed. Build the replacement for the
927
- * node's platform/arch (talon-node-<os>-<arch>) before calling.
964
+ * truncated transfer can never be installed.
965
+ *
966
+ * With no binary_path, the replacement is auto-resolved for the node's
967
+ * registered platform/arch (source build in a dev checkout, else the
968
+ * version-matched release download — see node-binaries.ts).
928
969
  */
929
970
  async updateNodeBinary(
930
971
  query: unknown,
931
- localBinaryPath: unknown,
972
+ localBinaryPath?: unknown,
932
973
  remotePath?: unknown,
933
974
  ): Promise<MeshToolResult> {
934
- const local =
935
- typeof localBinaryPath === "string" && localBinaryPath.trim()
936
- ? resolve(dirs.workspace, localBinaryPath.trim())
937
- : "";
938
- if (!local) return { ok: false, text: "A local binary path is required." };
939
975
  await this.load();
940
976
  const resolved = this.resolveDevice(query);
941
977
  if ("error" in resolved) return { ok: false, text: resolved.error };
@@ -947,6 +983,34 @@ export class MeshService {
947
983
  };
948
984
  }
949
985
 
986
+ let local: string;
987
+ let provenance = "";
988
+ if (typeof localBinaryPath === "string" && localBinaryPath.trim()) {
989
+ local = resolve(dirs.workspace, localBinaryPath.trim());
990
+ } else {
991
+ const goos = platformToGoos(target.platform);
992
+ if (!goos) {
993
+ return {
994
+ ok: false,
995
+ text: `${target.name} is a ${target.platform} device — update_node targets headless nodes only.`,
996
+ };
997
+ }
998
+ const goarch = normalizeGoarch(target.arch);
999
+ if (!goarch) {
1000
+ return {
1001
+ ok: false,
1002
+ text: `${target.name} has not advertised its CPU architecture (a node build from before arch reporting). Pass binary_path explicitly for this update — after it, the node advertises arch and future updates auto-resolve.`,
1003
+ };
1004
+ }
1005
+ try {
1006
+ const bin = await this.resolveNode(goos, goarch);
1007
+ local = bin.path;
1008
+ provenance = ` (auto-resolved ${bin.version} for ${goos}/${goarch} via ${bin.source})`;
1009
+ } catch (err) {
1010
+ return { ok: false, text: (err as Error).message };
1011
+ }
1012
+ }
1013
+
950
1014
  let sha256: string;
951
1015
  let size: number;
952
1016
  try {
@@ -989,12 +1053,145 @@ export class MeshService {
989
1053
  return {
990
1054
  ok: true,
991
1055
  text:
992
- `Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
1056
+ `Pushed ${formatBytes(size)}${provenance} and staged the update on ${target.name}. ` +
993
1057
  `${dispatched.result.message ?? "Restarting now."} ` +
994
1058
  `Confirm with get_device_status once it reconnects (appVersion should change).`,
995
1059
  };
996
1060
  }
997
1061
 
1062
+ // ── Node provisioning ──────────────────────────────────────────────────────
1063
+
1064
+ /**
1065
+ * `get_node_binary`: materialize a talon-node binary for any supported
1066
+ * platform/arch on the daemon host — source build in a dev checkout, else
1067
+ * the digest-verified release download (cached under ~/.talon/node-bin).
1068
+ */
1069
+ async getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
1070
+ const goos = normalizeGoos(os);
1071
+ const goarch = normalizeGoarch(arch);
1072
+ if (!goos || !goarch) {
1073
+ return { ok: false, text: unknownTargetText(os, arch) };
1074
+ }
1075
+ try {
1076
+ const bin = await this.resolveNode(goos, goarch);
1077
+ return {
1078
+ ok: true,
1079
+ text: `talon-node ${bin.version} for ${goos}/${goarch}: ${bin.path} (${formatBytes(bin.size)}, sha256 ${bin.sha256}, via ${bin.source})`,
1080
+ };
1081
+ } catch (err) {
1082
+ return { ok: false, text: (err as Error).message };
1083
+ }
1084
+ }
1085
+
1086
+ /**
1087
+ * `make_node_install_link`: mint a single-use provisioning URL on the
1088
+ * bridge and return the one command that turns a fresh host into a mesh
1089
+ * node — it fetches the installer script, which downloads the (digest-
1090
+ * verified) binary from the same bridge, installs it, pre-pins the bridge
1091
+ * certificate, and registers the boot service.
1092
+ */
1093
+ async makeNodeInstallLink(
1094
+ os: unknown,
1095
+ arch: unknown,
1096
+ name?: unknown,
1097
+ bridgeUrl?: unknown,
1098
+ ): Promise<MeshToolResult> {
1099
+ const goos = normalizeGoos(os);
1100
+ const goarch = normalizeGoarch(arch);
1101
+ if (!goos || !goarch) {
1102
+ return { ok: false, text: unknownTargetText(os, arch) };
1103
+ }
1104
+ const info = this.bridgeInfo;
1105
+ if (!info) {
1106
+ return {
1107
+ ok: false,
1108
+ text: "The native bridge isn't running, so there is nothing for a new node to connect to. Enable the native frontend first.",
1109
+ };
1110
+ }
1111
+ if (!info.token) {
1112
+ return {
1113
+ ok: false,
1114
+ text: "The bridge has no bearer token (loopback-only bind), and nodes authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
1115
+ };
1116
+ }
1117
+ const base = this.bridgeBaseUrl(info, bridgeUrl);
1118
+ if (typeof base !== "string") return { ok: false, text: base.error };
1119
+ let bin;
1120
+ try {
1121
+ bin = await this.resolveNode(goos, goarch);
1122
+ } catch (err) {
1123
+ return { ok: false, text: (err as Error).message };
1124
+ }
1125
+ const grant = this.provision.create({
1126
+ goos,
1127
+ goarch,
1128
+ ...(typeof name === "string" && name.trim() ? { name: name.trim() } : {}),
1129
+ binaryPath: bin.path,
1130
+ sha256: bin.sha256,
1131
+ size: bin.size,
1132
+ version: bin.version,
1133
+ bridgeUrl: base,
1134
+ bearerToken: info.token,
1135
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
1136
+ });
1137
+ return {
1138
+ ok: true,
1139
+ text: [
1140
+ `Run this on the new ${goos}/${goarch} host:`,
1141
+ "",
1142
+ ` ${installOneLiner(grant)}`,
1143
+ "",
1144
+ `It installs talon-node ${bin.version} (sha256-verified against ${grant.sha256.slice(0, 12)}…), pins the bridge certificate, and registers a boot service — the host appears on the mesh within a minute.`,
1145
+ `Single-use link, expires in 30 minutes. The host must be able to reach ${base}.`,
1146
+ ].join("\n"),
1147
+ };
1148
+ }
1149
+
1150
+ /** GET /node/install — serve a grant's installer script (single-use). */
1151
+ openNodeInstall(token: string): { script: string; filename: string } | null {
1152
+ return this.provision.openScript(token);
1153
+ }
1154
+
1155
+ /** GET /node/binary — serve a grant's binary (single-use). */
1156
+ openNodeBinary(token: string): { path: string; size: number } | null {
1157
+ return this.provision.openBinary(token);
1158
+ }
1159
+
1160
+ /**
1161
+ * The bridge base URL a NEW host should dial: an explicit override wins;
1162
+ * otherwise derive from the bridge's bind. A wildcard bind maps to this
1163
+ * host's first external IPv4; a loopback bind is unreachable from other
1164
+ * machines, so it's an error rather than a link that can't work.
1165
+ */
1166
+ private bridgeBaseUrl(
1167
+ info: MeshBridgeInfo,
1168
+ explicit?: unknown,
1169
+ ): string | { error: string } {
1170
+ if (typeof explicit === "string" && explicit.trim()) {
1171
+ const url = explicit.trim().replace(/\/+$/, "");
1172
+ if (!/^https?:\/\/\S+$/.test(url)) {
1173
+ return { error: `bridge_url must be an http(s) URL, got "${url}".` };
1174
+ }
1175
+ return url;
1176
+ }
1177
+ let host = info.host;
1178
+ if (host === "0.0.0.0" || host === "::") {
1179
+ const external = firstExternalIPv4();
1180
+ if (!external) {
1181
+ return {
1182
+ error:
1183
+ "Could not determine this host's external address — pass bridge_url explicitly (the URL the new node should dial).",
1184
+ };
1185
+ }
1186
+ host = external;
1187
+ } else if (isLoopbackAddress(host)) {
1188
+ return {
1189
+ error: `The bridge is bound to loopback (${host}), which other machines can't reach. Set native.host to a reachable address, or pass bridge_url if a tunnel exposes it.`,
1190
+ };
1191
+ }
1192
+ return `${info.scheme}://${host}:${info.port}`;
1193
+ }
1194
+
998
1195
  /**
999
1196
  * Chunked read of a remote file into a Buffer. Loops `read_file` with
1000
1197
  * increasing offsets until the device reports EOF.
@@ -1243,7 +1440,7 @@ export class MeshService {
1243
1440
 
1244
1441
  private deviceLine(device: DeviceInfo): string {
1245
1442
  const parts = [
1246
- `${device.name} [id: ${device.id}] (${device.platform})`,
1443
+ `${device.name} [id: ${device.id}] (${device.platform}${device.arch ? `/${device.arch}` : ""})`,
1247
1444
  device.online ? "online" : "offline",
1248
1445
  `last seen ${age(Date.now() - device.lastSeen)}`,
1249
1446
  ];
@@ -1445,6 +1642,28 @@ function requirePath(value: unknown): string | undefined {
1445
1642
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
1446
1643
  }
1447
1644
 
1645
+ /** Error text for an os/arch pair outside the talon-node build matrix. */
1646
+ function unknownTargetText(os: unknown, arch: unknown): string {
1647
+ return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
1648
+ (t) => `${t.goos}/${t.goarch}`,
1649
+ ).join(", ")} (macos ≡ darwin, x86_64 ≡ amd64, aarch64 ≡ arm64).`;
1650
+ }
1651
+
1652
+ /** First non-internal IPv4 on this host — the wildcard-bind fallback. */
1653
+ function firstExternalIPv4(): string | undefined {
1654
+ for (const list of Object.values(networkInterfaces())) {
1655
+ for (const iface of list ?? []) {
1656
+ if (!iface.internal && iface.family === "IPv4") return iface.address;
1657
+ }
1658
+ }
1659
+ return undefined;
1660
+ }
1661
+
1662
+ function isLoopbackAddress(host: string): boolean {
1663
+ const h = host.toLowerCase();
1664
+ return h === "localhost" || h === "::1" || h.startsWith("127.");
1665
+ }
1666
+
1448
1667
  /** Render an exec result: exit code headline + stdout/stderr blocks. */
1449
1668
  function formatExecResult(
1450
1669
  device: DeviceInfo,
@@ -15,6 +15,13 @@ export type DeviceInfo = {
15
15
  id: string;
16
16
  name: string;
17
17
  platform: DevicePlatform;
18
+ /**
19
+ * CPU architecture in Go spelling (amd64/arm64/arm), as talon-node
20
+ * advertises it. Lets the daemon pick the right release binary for
21
+ * update_node without being told. Absent for companion apps and node
22
+ * builds that predate arch reporting.
23
+ */
24
+ arch?: string;
18
25
  appVersion: string;
19
26
  online: boolean;
20
27
  /** Epoch milliseconds. */
@@ -83,6 +90,7 @@ export function toDeviceInfo(
83
90
  id: value.id,
84
91
  name: value.name,
85
92
  platform: value.platform,
93
+ ...(value.arch ? { arch: value.arch } : {}),
86
94
  appVersion: value.appVersion,
87
95
  online: now - value.lastSeen <= offlineAfterMs && value.online,
88
96
  lastSeen: value.lastSeen,
@@ -188,13 +188,14 @@ export const meshTools: ToolDefinition[] = [
188
188
  {
189
189
  name: "update_node",
190
190
  description:
191
- "Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary. Build the replacement for the node's platform/arch first (talon-node-<os>-<arch>). Confirm success with get_device_status afterwards (appVersion should change). This is the node counterpart of update_device — use update_device for Android companions.",
191
+ "Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary. With no binary_path, the daemon auto-resolves the right build for the node's registered platform/arch (source build in a dev checkout, else the digest-verified release download matching this Talon version) — the normal call is just update_node(device). Confirm success with get_device_status afterwards (appVersion should change). This is the node counterpart of update_device — use update_device for Android companions.",
192
192
  schema: {
193
193
  device: deviceParam,
194
194
  binary_path: z
195
195
  .string()
196
+ .optional()
196
197
  .describe(
197
- "Path to the new talon-node binary, relative to the workspace (or absolute), built for the node's OS/arch.",
198
+ "Optional explicit binary, relative to the workspace (or absolute), built for the node's OS/arch. Omit to auto-resolve.",
198
199
  ),
199
200
  remote_path: z
200
201
  .string()
@@ -206,4 +207,42 @@ export const meshTools: ToolDefinition[] = [
206
207
  execute: (params, bridge) => bridge("update_node", params),
207
208
  tag: "mesh",
208
209
  },
210
+ {
211
+ name: "get_node_binary",
212
+ description:
213
+ "Materialize a talon-node binary (the headless mesh device) for any supported platform/arch on the daemon host and return its path, version, and sha256. Resolution order: built from source when running in a dev checkout with Go, else the cached copy, else the digest-verified download from this Talon version's GitHub release — so it works on prebuilt installs with no toolchain. Use it to stage a binary for manual deployment (scp, device_push_file); for onboarding a fresh host prefer make_node_install_link, and for updating an existing node just call update_node (which resolves automatically).",
214
+ schema: {
215
+ os: z.string().describe("Target OS: linux, macos/darwin, or windows."),
216
+ arch: z
217
+ .string()
218
+ .describe("Target arch: amd64/x86_64, arm64/aarch64, or arm."),
219
+ },
220
+ execute: (params, bridge) => bridge("get_node_binary", params),
221
+ tag: "mesh",
222
+ },
223
+ {
224
+ name: "make_node_install_link",
225
+ description:
226
+ "Mint a single-use install link served by this daemon's bridge and return the one command that attaches a fresh Linux/macOS/Windows host to the mesh as a headless talon-node. Running it on the host downloads the installer script and binary from the bridge (sha256-verified), installs talon-node, pre-pins the bridge TLS certificate, embeds the bearer token, and registers a boot service — no toolchain, package manager, or manual config on the host. The link expires in 30 minutes and each leg serves exactly once; the host only needs to reach the bridge URL. Requires the native bridge running on a non-loopback bind with a token.",
227
+ schema: {
228
+ os: z.string().describe("Host OS: linux, macos/darwin, or windows."),
229
+ arch: z
230
+ .string()
231
+ .describe("Host arch: amd64/x86_64, arm64/aarch64, or arm."),
232
+ name: z
233
+ .string()
234
+ .optional()
235
+ .describe(
236
+ "Device name to register with (default: the host's hostname).",
237
+ ),
238
+ bridge_url: z
239
+ .string()
240
+ .optional()
241
+ .describe(
242
+ "Bridge base URL as reachable FROM the new host (e.g. https://100.64.0.7:19880). Default: derived from the bridge bind (wildcard binds use this host's first external IPv4).",
243
+ ),
244
+ },
245
+ execute: (params, bridge) => bridge("make_node_install_link", params),
246
+ tag: "mesh",
247
+ },
209
248
  ];
@@ -1194,6 +1194,8 @@ export function createNativeFrontend(
1194
1194
  completeCommand: (body) => mesh.completeCommand(body),
1195
1195
  acceptFileUpload: (token, body) => mesh.acceptFileUpload(token, body),
1196
1196
  openFileDownload: (token) => mesh.openFileDownload(token),
1197
+ openNodeInstall: (token) => mesh.openNodeInstall(token),
1198
+ openNodeBinary: (token) => mesh.openNodeBinary(token),
1197
1199
  };
1198
1200
 
1199
1201
  const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
@@ -1278,6 +1280,15 @@ export function createNativeFrontend(
1278
1280
  chats.restore();
1279
1281
  await server.start();
1280
1282
  const fingerprint = server.getFingerprint();
1283
+ // Tell the mesh how this bridge is reachable — everything a generated
1284
+ // node installer needs (make_node_install_link fails cleanly without it).
1285
+ mesh.setBridgeInfo({
1286
+ scheme: server.getScheme(),
1287
+ host: bridgeHost,
1288
+ port: server.getPort(),
1289
+ ...(bridgeToken ? { token: bridgeToken } : {}),
1290
+ ...(fingerprint ? { fingerprint } : {}),
1291
+ });
1281
1292
  await writeBridgeDiscovery({
1282
1293
  port: server.getPort(),
1283
1294
  token: bridgeToken,
@@ -1297,6 +1308,7 @@ export function createNativeFrontend(
1297
1308
  async stop() {
1298
1309
  unregisterMeshTransport?.();
1299
1310
  unregisterMeshTransport = null;
1311
+ mesh.setBridgeInfo(null);
1300
1312
  await removeBridgeDiscovery();
1301
1313
  await server.stop();
1302
1314
  await gateway.stop();
@@ -142,6 +142,10 @@ export type BridgeServerHandlers = {
142
142
  openFileDownload(
143
143
  token: string,
144
144
  ): Promise<{ path: string; size: number } | null>;
145
+ /** Resolve a node-provisioning token to its installer script, or null. */
146
+ openNodeInstall(token: string): { script: string; filename: string } | null;
147
+ /** Resolve a node-provisioning token to the binary to stream, or null. */
148
+ openNodeBinary(token: string): { path: string; size: number } | null;
145
149
  };
146
150
 
147
151
  const SSE_PING_MS = 25_000;
@@ -377,6 +381,45 @@ export class BridgeServer {
377
381
  });
378
382
  }
379
383
 
384
+ // Node provisioning runs PRE-AUTH by design: the target host holds no
385
+ // bridge credential yet — the single-use grant token (minted by
386
+ // make_node_install_link, expiring, one serve per leg) is the entire
387
+ // authorization, the same trust model as streamed-transfer tokens.
388
+ if (
389
+ method === "GET" &&
390
+ (path === "/node/install" || path === "/node/binary")
391
+ ) {
392
+ const token = url.searchParams.get("provision") ?? "";
393
+ const unknown = () =>
394
+ this.json(res, 404, {
395
+ ok: false,
396
+ error: "Unknown, expired, or already-used provisioning token",
397
+ });
398
+ if (!token) return unknown();
399
+ if (path === "/node/install") {
400
+ const install = this.handlers.openNodeInstall(token);
401
+ if (!install) return unknown();
402
+ res.writeHead(200, {
403
+ "Content-Type": "text/plain; charset=utf-8",
404
+ "Content-Disposition": `attachment; filename="${install.filename}"`,
405
+ ...this.corsHeaders(),
406
+ });
407
+ res.end(install.script);
408
+ return;
409
+ }
410
+ const binary = this.handlers.openNodeBinary(token);
411
+ if (!binary) return unknown();
412
+ res.writeHead(200, {
413
+ "Content-Type": "application/octet-stream",
414
+ "Content-Length": String(binary.size),
415
+ ...this.corsHeaders(),
416
+ });
417
+ const stream = createReadStream(binary.path);
418
+ stream.on("error", () => res.destroy());
419
+ stream.pipe(res);
420
+ return;
421
+ }
422
+
380
423
  if (auth !== "ok") {
381
424
  return this.json(res, 401, { ok: false, error: "Unauthorized" });
382
425
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The daemon's own release version, as a runtime value.
3
+ *
4
+ * A JSON import (not an fs read) so every packaging shape agrees: tsx and
5
+ * Node resolve the file, and `bun build --compile` inlines it into the
6
+ * standalone binary, where no package.json exists on disk. This is the
7
+ * version the node-binary resolver keys caches and release-asset URLs on,
8
+ * so it must exactly match the published tag (publish.yml verifies that).
9
+ */
10
+
11
+ import pkg from "../../package.json" with { type: "json" };
12
+
13
+ /** Talon's semver (e.g. "3.3.0") — the release identity of this build. */
14
+ export function talonVersion(): string {
15
+ return pkg.version;
16
+ }