talon-agent 5.20.1 → 5.22.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.
@@ -9,14 +9,28 @@
9
9
  * GET /node/install?provision=<token> → installer script (once)
10
10
  * GET /node/binary?provision=<token> → the binary itself (once)
11
11
  *
12
+ * A grant can also be minted WITHOUT a target ("auto"): the one-liner then
13
+ * reports the host's own `uname -s`/`uname -m` (or PowerShell's
14
+ * PROCESSOR_ARCHITECTURE) on the script fetch, and the caller resolves the
15
+ * binary and pins it into the grant before the script is rendered — so the
16
+ * script still embeds the digest and nothing about integrity changes.
17
+ *
12
18
  * The routes must work pre-auth — the whole point is a host that holds no
13
19
  * bridge credential yet — so the grant token IS the authorization, exactly
14
20
  * like streamed-transfer tokens (transfers/transfers.ts): random 192-bit, single-use
15
21
  * per leg, expiring unused. The script carries the bridge bearer token and
16
22
  * 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`.
23
+ * binary against the grant's digest, installs it, and registers the boot
24
+ * service via `talon-node install`.
25
+ *
26
+ * Over HTTPS both fetches (script and binary) are pinned to the bridge's own
27
+ * key — curl `--pinnedpubkey`, a certificate SHA-256 check on Windows — so a
28
+ * man in the middle can neither swap the script nor read the grant's bearer
29
+ * credential off the wire. Plain HTTP has nothing to pin.
30
+ *
31
+ * Every value that lands in generated sh/PowerShell text is validated to a
32
+ * quote-safe alphabet at the edge ({@link checkBridgeUrl}, device names) and
33
+ * escaped for its quoting context here anyway.
20
34
  */
21
35
 
22
36
  import { randomBytes } from "node:crypto";
@@ -41,13 +55,29 @@ export type NodeProvisionGrant = {
41
55
  bearerToken: string;
42
56
  /** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
43
57
  fingerprint?: string;
58
+ /** Base64 SHA-256 of the bridge key's SPKI — curl's pin (absent over HTTP). */
59
+ spkiPin?: string;
44
60
  createdAt: number;
45
61
  scriptUsed: boolean;
46
62
  binaryUsed: boolean;
47
63
  };
48
64
 
65
+ /** The fields an auto grant is missing until the target host reports in. */
66
+ export type NodeProvisionTarget = Pick<
67
+ NodeProvisionGrant,
68
+ "goos" | "goarch" | "binaryPath" | "sha256" | "size" | "version"
69
+ >;
70
+
71
+ /** An auto grant before its target is known — everything but the binary. */
72
+ export type PendingNodeProvision = Omit<
73
+ NodeProvisionGrant,
74
+ keyof NodeProvisionTarget | "scriptUsed" | "binaryUsed"
75
+ >;
76
+
49
77
  export class NodeProvisionStore {
50
78
  private readonly grants = new Map<string, NodeProvisionGrant>();
79
+ /** Auto grants whose target host hasn't fetched the script yet. */
80
+ private readonly pending = new Map<string, PendingNodeProvision>();
51
81
 
52
82
  constructor(private readonly ttlMs = GRANT_TTL_MS) {}
53
83
 
@@ -60,11 +90,7 @@ export class NodeProvisionStore {
60
90
  this.sweep();
61
91
  const full: NodeProvisionGrant = {
62
92
  ...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
- : {}),
93
+ ...sanitizedName(grant.name),
68
94
  token: randomBytes(24).toString("base64url"),
69
95
  createdAt: Date.now(),
70
96
  scriptUsed: false,
@@ -74,6 +100,48 @@ export class NodeProvisionStore {
74
100
  return full;
75
101
  }
76
102
 
103
+ /**
104
+ * Mint an auto grant: no target yet. {@link pin} fills it in when the host
105
+ * fetches the script and reports its own os/arch.
106
+ */
107
+ createAuto(
108
+ grant: Omit<PendingNodeProvision, "token" | "createdAt">,
109
+ ): PendingNodeProvision {
110
+ this.sweep();
111
+ const pending: PendingNodeProvision = {
112
+ ...grant,
113
+ ...sanitizedName(grant.name),
114
+ token: randomBytes(24).toString("base64url"),
115
+ createdAt: Date.now(),
116
+ };
117
+ this.pending.set(pending.token, pending);
118
+ return pending;
119
+ }
120
+
121
+ /** Whether `token` is a live auto grant still waiting for its target. */
122
+ isPending(token: string): boolean {
123
+ this.sweep();
124
+ return this.pending.has(token);
125
+ }
126
+
127
+ /**
128
+ * Bind an auto grant to the binary for its host. It becomes an ordinary
129
+ * grant under the same token (same TTL clock); a second pin is a no-op.
130
+ */
131
+ pin(token: string, target: NodeProvisionTarget): boolean {
132
+ this.sweep();
133
+ const pending = this.pending.get(token);
134
+ if (!pending) return false;
135
+ this.pending.delete(token);
136
+ this.grants.set(token, {
137
+ ...pending,
138
+ ...target,
139
+ scriptUsed: false,
140
+ binaryUsed: false,
141
+ });
142
+ return true;
143
+ }
144
+
77
145
  /** Serve the installer script for a live grant — once. */
78
146
  openScript(token: string): { script: string; filename: string } | null {
79
147
  const grant = this.claim(token, "scriptUsed");
@@ -111,49 +179,216 @@ export class NodeProvisionStore {
111
179
  for (const [token, grant] of this.grants) {
112
180
  if (grant.createdAt < cutoff) this.grants.delete(token);
113
181
  }
182
+ for (const [token, grant] of this.pending) {
183
+ if (grant.createdAt < cutoff) this.pending.delete(token);
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Device names are injected into generated shell/PowerShell text — keep them
190
+ * to characters that can't break out of a quoted string.
191
+ */
192
+ function sanitizedName(name: string | undefined): { name?: string } {
193
+ return name ? { name: name.replace(/[^\w .-]+/g, "").slice(0, 64) } : {};
194
+ }
195
+
196
+ /**
197
+ * Anything that could end, expand, or escape inside a quoted sh,
198
+ * PowerShell, or cmd.exe string: both quote kinds, `$`, backtick,
199
+ * backslash, `%` (cmd.exe expands `%VAR%` even inside quotes), and
200
+ * everything outside printable ASCII — whitespace, control characters, and
201
+ * the Unicode "smart" quotes PowerShell also treats as quotes.
202
+ */
203
+ const UNSAFE_URL_CHAR = /["'`$\\%]|[^\x21-\x7e]/;
204
+
205
+ /**
206
+ * Validate a bridge base URL before it is baked into installer scripts: an
207
+ * http(s) URL with a host, made only of characters that are inert in every
208
+ * quoting context the installers use. Returns the URL without trailing
209
+ * slashes, or why it was refused.
210
+ */
211
+ export function checkBridgeUrl(
212
+ raw: string,
213
+ label = "bridge_url",
214
+ ): string | { error: string } {
215
+ const url = raw.trim().replace(/\/+$/, "");
216
+ const refused = {
217
+ error: `${label} must be a plain http(s) URL (no quotes, $, backticks, backslashes, % or whitespace), got ${JSON.stringify(url)}.`,
218
+ };
219
+ if (UNSAFE_URL_CHAR.test(url)) return refused;
220
+ let parsed: URL;
221
+ try {
222
+ parsed = new URL(url);
223
+ } catch {
224
+ return refused;
114
225
  }
226
+ const web = parsed.protocol === "http:" || parsed.protocol === "https:";
227
+ return web && parsed.hostname ? url : refused;
228
+ }
229
+
230
+ // ── Quoting ──────────────────────────────────────────────────────────────────
231
+
232
+ /** Escape for a double-quoted POSIX sh string. */
233
+ function sh(value: string): string {
234
+ return value.replace(/["$`\\]/g, "\\$&");
115
235
  }
116
236
 
237
+ /** Escape for a double-quoted PowerShell string (backtick is the escape). */
238
+ function ps(value: string): string {
239
+ return value.replace(/["$`“”„]/g, "`$&");
240
+ }
241
+
242
+ /** Escape for a single-quoted PowerShell string (quotes double up). */
243
+ function psq(value: string): string {
244
+ return value.replace(/['‘’‚‛]/g, "$&$&");
245
+ }
246
+
247
+ /** Keep a value on one line — for `#` comment lines. */
248
+ function oneLine(value: string): string {
249
+ return value.replace(/[\r\n]+/g, " ");
250
+ }
251
+
252
+ // ── Pinning ──────────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Pinned fetches for PowerShell, compiled with Add-Type so the certificate
256
+ * callback is a real .NET delegate: a script-block callback only works on
257
+ * the pipeline thread (Windows PowerShell 5.1's synchronous iwr), while
258
+ * pwsh 7's HttpClient calls it from the thread pool, where no runspace
259
+ * exists. HttpWebRequest's per-request callback exists on .NET Framework
260
+ * 4.5+ and every .NET Core, so one source serves both shells; -IgnoreWarnings
261
+ * because pwsh 7 would fail on WebRequest's obsolete warning.
262
+ *
263
+ * The check: the presented certificate's SHA-256 (BitConverter's `AB-CD-…`
264
+ * form) must equal `[TalonPin]::Pin` — the same certificate hash talon-node
265
+ * pins afterwards. An unset pin fails closed. The source holds no quotes,
266
+ * `$` or `%`, so it survives a PowerShell single-quoted string inside a
267
+ * cmd.exe (or PowerShell) double-quoted `-Command` argument.
268
+ */
269
+ const PIN_TYPE_SOURCE =
270
+ "using System;using System.IO;using System.Net;using System.Security.Cryptography;" +
271
+ "public static class TalonPin{public static string Pin;" +
272
+ "static WebResponse Open(string u){var r=(HttpWebRequest)WebRequest.Create(u);" +
273
+ "r.ServerCertificateValidationCallback=(s,c,h,e)=>c!=null&&string.Equals(" +
274
+ "BitConverter.ToString(SHA256.Create().ComputeHash(c.GetRawCertData())),Pin,StringComparison.OrdinalIgnoreCase);" +
275
+ "return r.GetResponse();}" +
276
+ "public static string Get(string u){using(var w=Open(u))using(var t=new StreamReader(w.GetResponseStream()))return t.ReadToEnd();}" +
277
+ "public static void Save(string u,string p){using(var w=Open(u))using(var i=w.GetResponseStream())using(var o=File.Create(p))i.CopyTo(o);}}";
278
+
279
+ /** `abcd…` → `AB-CD-…`, the form BitConverter.ToString gives a hash. */
280
+ function dashedFingerprint(fingerprint: string): string {
281
+ return (fingerprint.match(/.{2}/g) ?? []).join("-").toUpperCase();
282
+ }
283
+
284
+ /** PowerShell statements that load TalonPin and arm it with the pin. */
285
+ function psPinSetup(fingerprint: string): string {
286
+ return (
287
+ `if (-not ('TalonPin' -as [type])) { Add-Type -IgnoreWarnings -TypeDefinition '${psq(PIN_TYPE_SOURCE)}' }; ` +
288
+ `[TalonPin]::Pin = '${psq(dashedFingerprint(fingerprint))}'`
289
+ );
290
+ }
291
+
292
+ /**
293
+ * curl flags for a bridge fetch. With a pin, `-k` stays on purpose: the
294
+ * bridge cert is self-signed and curl still chain-verifies without `-k`,
295
+ * even when the pin matches — while `--pinnedpubkey` is enforced with or
296
+ * without `-k`. So the pin, not the chain, is the real check. Without one
297
+ * (plain HTTP, nothing to pin) the flags are unchanged.
298
+ */
299
+ function curlFlags(spkiPin: string | undefined): string {
300
+ return spkiPin ? `-fsSk --pinnedpubkey "sha256//${sh(spkiPin)}"` : "-fsSk";
301
+ }
302
+
303
+ // ── Generated commands ───────────────────────────────────────────────────────
304
+
117
305
  /** The command a human (or the model over SSH) runs on the target host. */
118
306
  export function installOneLiner(grant: NodeProvisionGrant): string {
119
307
  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.
308
+ if (grant.goos !== "windows") {
309
+ return `curl ${curlFlags(grant.spkiPin)} "${sh(url)}" | sh`;
310
+ }
311
+ if (grant.fingerprint) {
312
+ // No `$` anywhere, so the line means the same pasted into cmd.exe or a
313
+ // PowerShell prompt (whose double quotes would expand variables).
125
314
  return (
126
315
  `powershell -ExecutionPolicy Bypass -Command "` +
127
- `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
128
- `iwr -UseBasicParsing '${url}' | Select-Object -ExpandProperty Content | iex"`
316
+ `${psPinSetup(grant.fingerprint)}; ` +
317
+ `iex ([TalonPin]::Get('${psq(url)}'))"`
129
318
  );
130
319
  }
131
- return `curl -fsSk "${url}" | sh`;
320
+ // Plain HTTP: nothing to pin, and the trust-all callback is inert. The
321
+ // line is meant for cmd.exe, where $true survives the outer double quotes.
322
+ return (
323
+ `powershell -ExecutionPolicy Bypass -Command "` +
324
+ `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
325
+ `iwr -UseBasicParsing '${psq(url)}' | Select-Object -ExpandProperty Content | iex"`
326
+ );
132
327
  }
133
328
 
134
329
  /**
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.
330
+ * The two commands for an auto grant — one per shell family, pinned exactly
331
+ * like {@link installOneLiner}. Each appends its host's platform to the
332
+ * script fetch; the bridge maps `Linux`/`Darwin`, `x86_64`/`aarch64`/
333
+ * `armv7l`, and PowerShell's `AMD64`/`ARM64` through the same normalizers
334
+ * the tool arguments use.
335
+ *
336
+ * The PowerShell line carries one `$` (`$env:PROCESSOR_ARCHITECTURE`): from
337
+ * cmd.exe it reaches PowerShell verbatim, and from a PowerShell prompt the
338
+ * outer double quotes expand it first — the same value either way.
339
+ */
340
+ export function autoInstallOneLiners(
341
+ grant: Pick<
342
+ NodeProvisionGrant,
343
+ "bridgeUrl" | "token" | "fingerprint" | "spkiPin"
344
+ >,
345
+ ): { posix: string; windows: string } {
346
+ const url = `${grant.bridgeUrl}/node/install?provision=${grant.token}`;
347
+ const posix = `curl ${curlFlags(grant.spkiPin)} "${sh(url)}&os=$(uname -s)&arch=$(uname -m)" | sh`;
348
+ const psUrl = `('${psq(url)}&os=windows&arch=' + $env:PROCESSOR_ARCHITECTURE)`;
349
+ const windows = grant.fingerprint
350
+ ? `powershell -ExecutionPolicy Bypass -Command "` +
351
+ `${psPinSetup(grant.fingerprint)}; ` +
352
+ `iex ([TalonPin]::Get${psUrl})"`
353
+ : `powershell -ExecutionPolicy Bypass -Command "` +
354
+ `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
355
+ `iwr -UseBasicParsing ${psUrl} | Select-Object -ExpandProperty Content | iex"`;
356
+ return { posix, windows };
357
+ }
358
+
359
+ /**
360
+ * What an auto grant serves when the host's platform has no build or the
361
+ * binary can't be resolved: a script that fails loudly. Valid as both POSIX
362
+ * sh and PowerShell, since which shell is piping it isn't known.
363
+ */
364
+ export function installRefusalScript(reason: string): string {
365
+ const safe = reason.replace(/["'`$\\%\n\r]+/g, " ").slice(0, 300);
366
+ return `echo "talon-node install refused: ${safe}"\nexit 1\n`;
367
+ }
368
+
369
+ /**
370
+ * POSIX installer: fetch the binary over the same grant (pinned like the
371
+ * script fetch), verify its digest, install it next to the node's config,
372
+ * and register the boot service. The certificate fingerprint is pre-pinned
373
+ * into the node's config for every connection after install.
140
374
  */
141
375
  function shellInstaller(grant: NodeProvisionGrant): string {
142
- const nameFlag = grant.name ? ` --name "${grant.name}"` : "";
376
+ const nameFlag = grant.name ? ` --name "${sh(grant.name)}"` : "";
143
377
  const fpFlag = grant.fingerprint
144
- ? ` --fingerprint "${grant.fingerprint}"`
378
+ ? ` --fingerprint "${sh(grant.fingerprint)}"`
145
379
  : "";
380
+ const target = `${grant.version} (${grant.goos}/${grant.goarch})`;
146
381
  return `#!/bin/sh
147
- # talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
382
+ # talon-node installer — generated by Talon ${oneLine(`${grant.version} for ${grant.goos}/${grant.goarch}`)}
148
383
  set -eu
149
- BRIDGE="${grant.bridgeUrl}"
150
- SHA="${grant.sha256}"
384
+ BRIDGE="${sh(grant.bridgeUrl)}"
385
+ SHA="${sh(grant.sha256)}"
151
386
  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
387
  mkdir -p "$DEST_DIR"
153
388
  TMP="$(mktemp)"
154
389
  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"
390
+ echo "Downloading talon-node ${sh(target)}..."
391
+ curl ${curlFlags(grant.spkiPin)} "$BRIDGE/node/binary?provision=${sh(grant.token)}" -o "$TMP"
157
392
  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
393
  if [ "$GOT" != "$SHA" ]; then echo "talon-node download failed its checksum — refusing to install" >&2; exit 1; fi
159
394
  BIN="$DEST_DIR/talon-node"
@@ -161,37 +396,46 @@ mv "$TMP" "$BIN"
161
396
  chmod 0755 "$BIN"
162
397
  trap - EXIT
163
398
  echo "Installed $BIN"
164
- "$BIN" install --bridge "$BRIDGE" --token "${grant.bearerToken}"${fpFlag}${nameFlag}
399
+ "$BIN" install --bridge "$BRIDGE" --token "${sh(grant.bearerToken)}"${fpFlag}${nameFlag}
165
400
  echo "Done — this host is now on the mesh. Check with: $BIN status"
166
401
  `;
167
402
  }
168
403
 
404
+ /** The PowerShell lines that download the binary to `$bin`. */
405
+ function psBinaryFetch(grant: NodeProvisionGrant): string {
406
+ const url = `"$bridge/node/binary?provision=${ps(grant.token)}"`;
407
+ return grant.fingerprint
408
+ ? `${psPinSetup(grant.fingerprint)}\n[TalonPin]::Save(${url}, $bin)`
409
+ : `[Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }\n` +
410
+ `Invoke-WebRequest -UseBasicParsing ${url} -OutFile $bin`;
411
+ }
412
+
169
413
  /**
170
- * PowerShell installer (Windows PowerShell 5+ compatible). The binary lands in
171
- * the installing user's %LOCALAPPDATA% and `talon-node install` registers a
172
- * boot task that runs as that same user (never SYSTEM — a SYSTEM task would
414
+ * PowerShell installer (Windows PowerShell 5.1 and pwsh 7). The binary lands
415
+ * in the installing user's %LOCALAPPDATA% and `talon-node install` registers
416
+ * a boot task that runs as that same user (never SYSTEM — a SYSTEM task would
173
417
  * run a binary the user can rewrite). Registering a boot task needs an
174
418
  * elevated PowerShell.
175
419
  */
176
420
  function powershellInstaller(grant: NodeProvisionGrant): string {
177
- const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
421
+ const nameArg = grant.name ? `, "--name", "${ps(grant.name)}"` : "";
178
422
  const fpArg = grant.fingerprint
179
- ? `, "--fingerprint", "${grant.fingerprint}"`
423
+ ? `, "--fingerprint", "${ps(grant.fingerprint)}"`
180
424
  : "";
181
- return `# talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
425
+ const target = `${grant.version} (${grant.goos}/${grant.goarch})`;
426
+ return `# talon-node installer — generated by Talon ${oneLine(`${grant.version} for ${grant.goos}/${grant.goarch}`)}
182
427
  $ErrorActionPreference = "Stop"
183
- [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
184
- $bridge = "${grant.bridgeUrl}"
428
+ $bridge = "${ps(grant.bridgeUrl)}"
185
429
  $dest = Join-Path $env:LOCALAPPDATA "talon-node"
186
430
  New-Item -ItemType Directory -Force -Path $dest | Out-Null
187
431
  $bin = Join-Path $dest "talon-node.exe"
188
- Write-Host "Downloading talon-node ${grant.version} (${grant.goos}/${grant.goarch})..."
189
- Invoke-WebRequest -UseBasicParsing "$bridge/node/binary?provision=${grant.token}" -OutFile $bin
432
+ Write-Host "Downloading talon-node ${ps(target)}..."
433
+ ${psBinaryFetch(grant)}
190
434
  $got = (Get-FileHash $bin -Algorithm SHA256).Hash.ToLower()
191
- if ($got -ne "${grant.sha256}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
435
+ if ($got -ne "${ps(grant.sha256)}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
192
436
  Write-Host "Installed $bin"
193
437
  Write-Host "Registering the boot task to run as $env:USERDOMAIN\\$env:USERNAME (not SYSTEM)..."
194
- & $bin install --bridge $bridge --token "${grant.bearerToken}"${fpArg}${nameArg}
438
+ & $bin install --bridge $bridge --token "${ps(grant.bearerToken)}"${fpArg}${nameArg}
195
439
  Write-Host "Done - this host is now on the mesh. Check with: $bin status"
196
440
  `;
197
441
  }
@@ -15,10 +15,11 @@
15
15
  */
16
16
 
17
17
  import { randomUUID } from "node:crypto";
18
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
18
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
19
19
  import { tmpdir } from "node:os";
20
20
  import { basename, dirname, join, resolve } from "node:path";
21
21
  import type { Readable } from "node:stream";
22
+ import { logDebug } from "../../../util/log.js";
22
23
  import { dirs } from "../../../util/paths.js";
23
24
  import {
24
25
  formatBytes,
@@ -127,6 +128,14 @@ const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
127
128
  * Resolved lazily (not at module load) so a test that mocks `util/paths`
128
129
  * doesn't hit its workspace binding before initialization.
129
130
  */
131
+ /**
132
+ * The opt-in rollback flag for install_apk / update_node. Sent only when set,
133
+ * so the default command shape is exactly what older devices already accept.
134
+ */
135
+ function downgradeParam(allow: unknown): { allow_downgrade?: true } {
136
+ return allow === true ? { allow_downgrade: true } : {};
137
+ }
138
+
130
139
  function pullDir(): string {
131
140
  return resolve(dirs.workspace, "mesh-pull");
132
141
  }
@@ -202,7 +211,7 @@ export class DeviceFiles {
202
211
  // is normally already resolved — the grace window only catches a device
203
212
  // that claims success without having streamed anything.
204
213
  try {
205
- const bytes = await Promise.race([
214
+ const receipt = await Promise.race([
206
215
  done,
207
216
  new Promise<never>((_, rej) =>
208
217
  setTimeout(
@@ -216,13 +225,83 @@ export class DeviceFiles {
216
225
  ).unref?.(),
217
226
  ),
218
227
  ]);
219
- return { bytes };
228
+ // The device hashed what it sent; the store hashed what arrived.
229
+ const mismatch = digestMismatch(
230
+ dispatched.result,
231
+ receipt.sha256,
232
+ `pull of ${remote} from ${target.name}`,
233
+ );
234
+ if (mismatch) {
235
+ await rm(dest, { force: true }).catch(() => {});
236
+ return {
237
+ error: `Integrity check failed pulling ${remote} from ${target.name}: ${mismatch}. The received copy was discarded.`,
238
+ };
239
+ }
240
+ return { bytes: receipt.bytes };
220
241
  } catch (err) {
221
242
  this.transfers.cancel(token);
222
243
  return { error: (err as Error).message };
223
244
  }
224
245
  }
225
246
 
247
+ /**
248
+ * Streamed daemon→device transfer: one command round trip, the body as a
249
+ * single raw HTTP response. The source's SHA-256 rides along so the device
250
+ * can refuse to rename a corrupted temp file into place; its reported
251
+ * digest is checked here too.
252
+ */
253
+ private async pushViaStream(
254
+ target: DeviceInfo,
255
+ local: string,
256
+ remote: string,
257
+ ): Promise<MeshToolResult> {
258
+ const started = Date.now();
259
+ // Hashing the source also sizes the budget (a local read, not a mesh
260
+ // round trip). A source we cannot read still gets a bounded, looser
261
+ // budget and no digest — this decides a timeout, not whether the
262
+ // transfer may start.
263
+ const source = await hashFile(local).catch(() => undefined);
264
+ const { token } = this.transfers.createPush(target.id, local);
265
+ const dispatched = await this.host.dispatchCommand(
266
+ target.id,
267
+ "download_file",
268
+ { token, path: remote, ...(source && { sha256: source.sha256 }) },
269
+ streamTransferTimeoutMs(source?.size),
270
+ );
271
+ if ("error" in dispatched) {
272
+ this.transfers.cancel(token);
273
+ return { ok: false, text: dispatched.error };
274
+ }
275
+ if (!dispatched.result.ok) {
276
+ this.transfers.cancel(token);
277
+ return {
278
+ ok: false,
279
+ text:
280
+ dispatched.result.message ??
281
+ `${target.name} could not download ${local}.`,
282
+ };
283
+ }
284
+ const mismatch =
285
+ source &&
286
+ digestMismatch(
287
+ dispatched.result,
288
+ source.sha256,
289
+ `push of ${local} to ${target.name}`,
290
+ );
291
+ if (mismatch) {
292
+ return {
293
+ ok: false,
294
+ text: `Integrity check failed pushing ${local} to ${remote} on ${target.name}: ${mismatch}.`,
295
+ };
296
+ }
297
+ const bytes = dispatched.result.data?.bytesWritten;
298
+ const size = typeof bytes === "number" ? bytes : 0;
299
+ return {
300
+ ok: true,
301
+ text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
302
+ };
303
+ }
304
+
226
305
  /**
227
306
  * Raw file bytes off a device — the structured primitive under both the
228
307
  * human-readable tool below and the native read/edit path (which must not
@@ -381,40 +460,7 @@ export class DeviceFiles {
381
460
  if ("error" in resolved) return { ok: false, text: resolved.error };
382
461
  const target = resolved.target;
383
462
  if (this.canStream(target, "download_file")) {
384
- const started = Date.now();
385
- // The source is on this host, so sizing the budget costs a local stat
386
- // rather than a mesh round trip. A source we cannot stat still gets a
387
- // bounded (if looser) budget — this decides a timeout, not whether
388
- // the transfer is allowed to start.
389
- const localSize = await stat(local)
390
- .then((s) => s.size)
391
- .catch(() => undefined);
392
- const { token } = this.transfers.createPush(target.id, local);
393
- const dispatched = await this.host.dispatchCommand(
394
- target.id,
395
- "download_file",
396
- { token, path: remote },
397
- streamTransferTimeoutMs(localSize),
398
- );
399
- if ("error" in dispatched) {
400
- this.transfers.cancel(token);
401
- return { ok: false, text: dispatched.error };
402
- }
403
- if (!dispatched.result.ok) {
404
- this.transfers.cancel(token);
405
- return {
406
- ok: false,
407
- text:
408
- dispatched.result.message ??
409
- `${target.name} could not download ${local}.`,
410
- };
411
- }
412
- const bytes = dispatched.result.data?.bytesWritten;
413
- const size = typeof bytes === "number" ? bytes : 0;
414
- return {
415
- ok: true,
416
- text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
417
- };
463
+ return this.pushViaStream(target, local, remote);
418
464
  }
419
465
  let data: Buffer;
420
466
  try {
@@ -450,6 +496,7 @@ export class DeviceFiles {
450
496
  query: unknown,
451
497
  localApkPath: unknown,
452
498
  remotePath?: unknown,
499
+ allowDowngrade?: unknown,
453
500
  ): Promise<MeshToolResult> {
454
501
  const local =
455
502
  typeof localApkPath === "string" && localApkPath.trim()
@@ -496,7 +543,7 @@ export class DeviceFiles {
496
543
  const dispatched = await this.host.dispatchCommand(
497
544
  target.id,
498
545
  "install_apk",
499
- { path: remote, sha256 },
546
+ { path: remote, sha256, ...downgradeParam(allowDowngrade) },
500
547
  this.host.commandTimeoutMs,
501
548
  );
502
549
  if ("error" in dispatched) return { ok: false, text: dispatched.error };
@@ -535,6 +582,7 @@ export class DeviceFiles {
535
582
  query: unknown,
536
583
  localBinaryPath?: unknown,
537
584
  remotePath?: unknown,
585
+ allowDowngrade?: unknown,
538
586
  ): Promise<MeshToolResult> {
539
587
  await this.host.load();
540
588
  const resolved = this.host.resolveDevice(query);
@@ -604,7 +652,7 @@ export class DeviceFiles {
604
652
  const dispatched = await this.host.dispatchCommand(
605
653
  target.id,
606
654
  "update_node",
607
- { path: remote, sha256 },
655
+ { path: remote, sha256, ...downgradeParam(allowDowngrade) },
608
656
  this.host.commandTimeoutMs,
609
657
  );
610
658
  if ("error" in dispatched) return { ok: false, text: dispatched.error };
@@ -738,6 +786,29 @@ export class DeviceFiles {
738
786
  }
739
787
  }
740
788
 
789
+ /**
790
+ * Compare the SHA-256 a device reported for a streamed transfer with the one
791
+ * the daemon holds. Returns a description of a mismatch, or null when they
792
+ * agree. A device build that predates transfer digests reports none; the
793
+ * check is then skipped (with a debug line), never failed.
794
+ */
795
+ function digestMismatch(
796
+ result: DeviceCommandResult,
797
+ expected: string,
798
+ what: string,
799
+ ): string | null {
800
+ const reported = result.data?.sha256;
801
+ if (typeof reported !== "string" || !reported) {
802
+ logDebug(
803
+ "mesh",
804
+ `${what}: device reported no sha256 (older build) — skipping the integrity check`,
805
+ );
806
+ return null;
807
+ }
808
+ if (reported.toLowerCase() === expected.toLowerCase()) return null;
809
+ return `the device reported sha256 ${reported}, the daemon has ${expected}`;
810
+ }
811
+
741
812
  /** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
742
813
  function transferRate(bytes: number, startedAtMs: number): string {
743
814
  const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);