talon-agent 3.33.0 → 3.33.2
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 +3 -3
- package/src/backend/kilo/factory.ts +39 -119
- package/src/backend/kilo/handler/index.ts +1 -11
- package/src/backend/kilo/handler/message.ts +20 -317
- package/src/backend/kilo/server.ts +55 -272
- package/src/backend/kilo/sessions.ts +5 -83
- package/src/backend/opencode/factory.ts +41 -118
- package/src/backend/opencode/handler/index.ts +1 -11
- package/src/backend/opencode/handler/message.ts +20 -321
- package/src/backend/opencode/server.ts +57 -217
- package/src/backend/opencode/sessions.ts +5 -83
- package/src/backend/remote-server/chat-turn.ts +359 -0
- package/src/backend/remote-server/factory.ts +150 -0
- package/src/backend/remote-server/index.ts +14 -19
- package/src/backend/remote-server/server-bindings.ts +232 -0
- package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
- package/src/core/mesh/bridge-links.ts +319 -0
- package/src/core/mesh/common.ts +30 -0
- package/src/core/mesh/device-files.ts +710 -0
- package/src/core/mesh/service.ts +72 -894
- package/src/frontend/discord/handlers/access.ts +7 -56
- package/src/frontend/discord/handlers/state.ts +15 -8
- package/src/frontend/native/server.ts +391 -323
- package/src/frontend/shared/access.ts +152 -0
- package/src/frontend/telegram/handlers/access.ts +8 -25
- package/src/frontend/telegram/handlers/queue.ts +3 -35
- package/src/frontend/telegram/handlers/state.ts +15 -8
- package/src/backend/kilo/events.ts +0 -60
- package/src/backend/kilo/handler/state.ts +0 -9
- package/src/backend/opencode/handler/state.ts +0 -9
- package/src/backend/opencode/handler/turn.ts +0 -314
package/src/core/mesh/service.ts
CHANGED
|
@@ -24,34 +24,29 @@
|
|
|
24
24
|
* With no transport registered (daemon running without the native bridge),
|
|
25
25
|
* everything degrades gracefully: locate answers from the last persisted
|
|
26
26
|
* fix immediately, and commands fail fast with a clear explanation.
|
|
27
|
+
*
|
|
28
|
+
* The service keeps the registry, the transport fan-out, the command
|
|
29
|
+
* channel, device resolution, and the model-facing summaries. Two
|
|
30
|
+
* collaborators own the rest and are reached through thin delegations so
|
|
31
|
+
* the public surface stays one object: {@link DeviceFiles} (chunked and
|
|
32
|
+
* streamed file transfer, the companion/node self-update flows) and
|
|
33
|
+
* {@link BridgeLinks} (node provisioning, companion pairing, reachability).
|
|
27
34
|
*/
|
|
28
35
|
|
|
29
36
|
import { randomUUID } from "node:crypto";
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
|
-
import { createReadStream } from "node:fs";
|
|
32
|
-
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
33
|
-
import { networkInterfaces, tmpdir } from "node:os";
|
|
34
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
35
37
|
import type { Readable } from "node:stream";
|
|
36
38
|
import { clampExecOutput } from "../../util/exec-output.js";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
NODE_TARGETS,
|
|
40
|
-
normalizeGoarch,
|
|
41
|
-
normalizeGoos,
|
|
42
|
-
platformToGoos,
|
|
43
|
-
resolveNodeBinary,
|
|
44
|
-
type NodeBinaryResolver,
|
|
45
|
-
} from "./node-binaries.js";
|
|
39
|
+
import { BridgeLinks, type MeshBridgeInfo } from "./bridge-links.js";
|
|
46
40
|
import {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
age,
|
|
42
|
+
formatBytes,
|
|
43
|
+
FS_COMMAND_TIMEOUT_MS,
|
|
44
|
+
requirePath,
|
|
45
|
+
type MeshToolResult,
|
|
46
|
+
} from "./common.js";
|
|
47
|
+
import { DeviceFiles } from "./device-files.js";
|
|
48
|
+
import { resolveNodeBinary, type NodeBinaryResolver } from "./node-binaries.js";
|
|
53
49
|
import { MeshRegistry } from "./registry.js";
|
|
54
|
-
import { TransferStore } from "./transfers.js";
|
|
55
50
|
import type {
|
|
56
51
|
DeviceCommand,
|
|
57
52
|
DeviceCommandResult,
|
|
@@ -67,7 +62,7 @@ export type MeshTransport = {
|
|
|
67
62
|
command(command: DeviceCommand): void;
|
|
68
63
|
};
|
|
69
64
|
|
|
70
|
-
export type
|
|
65
|
+
export type { MeshToolResult } from "./common.js";
|
|
71
66
|
|
|
72
67
|
/** Outcome of pinging one device (see {@link MeshService.pingAll}). */
|
|
73
68
|
export type MeshPingResult = {
|
|
@@ -91,22 +86,7 @@ export type MeshServiceOptions = {
|
|
|
91
86
|
nodeBinaryResolver?: NodeBinaryResolver;
|
|
92
87
|
};
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
* What the native bridge tells the mesh about itself once it's listening —
|
|
96
|
-
* everything a generated node installer needs to point a fresh host here.
|
|
97
|
-
* Registered by the native frontend after server start; null when the
|
|
98
|
-
* bridge isn't running (provisioning tools then fail with a clear reason).
|
|
99
|
-
*/
|
|
100
|
-
export type MeshBridgeInfo = {
|
|
101
|
-
scheme: "http" | "https";
|
|
102
|
-
/** The bind host from config — may be a wildcard (0.0.0.0/::). */
|
|
103
|
-
host: string;
|
|
104
|
-
port: number;
|
|
105
|
-
/** Bearer token clients authenticate with (absent on open loopback). */
|
|
106
|
-
token?: string;
|
|
107
|
-
/** TLS certificate SHA-256 (absent over plain HTTP). */
|
|
108
|
-
fingerprint?: string;
|
|
109
|
-
};
|
|
89
|
+
export type { MeshBridgeInfo } from "./bridge-links.js";
|
|
110
90
|
|
|
111
91
|
const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
|
|
112
92
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
@@ -120,44 +100,6 @@ const MAX_HISTORY_LINES = 24;
|
|
|
120
100
|
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
121
101
|
/** Hard ceiling on a caller-requested exec timeout. */
|
|
122
102
|
const MAX_EXEC_TIMEOUT_MS = 300_000;
|
|
123
|
-
/** Timeout for a single filesystem command (list/stat/one chunk/etc.). */
|
|
124
|
-
const FS_COMMAND_TIMEOUT_MS = 30_000;
|
|
125
|
-
/**
|
|
126
|
-
* Bytes of file payload per FALLBACK transfer chunk (base64 on the wire).
|
|
127
|
-
* The chunked command channel costs one full mesh round trip per chunk, so
|
|
128
|
-
* it's only used for app builds that don't advertise the streaming commands
|
|
129
|
-
* (`upload_file`/`download_file`) — modern builds move file bodies as a
|
|
130
|
-
* single raw HTTP stream instead (see TransferStore). 1MB keeps even the
|
|
131
|
-
* fallback tolerable without bloating a single SSE frame too far.
|
|
132
|
-
*/
|
|
133
|
-
const FILE_CHUNK_BYTES = 1024 * 1024;
|
|
134
|
-
/** Wall-clock budget for one streamed transfer (command dispatch → done). */
|
|
135
|
-
const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
|
|
136
|
-
/** readFileBytes switches to the streaming path above this size. */
|
|
137
|
-
const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
138
|
-
/**
|
|
139
|
-
* Hard ceiling on a chunked (command-channel) transfer. The chunked path
|
|
140
|
-
* assembles the whole file in daemon memory one mesh round trip at a time —
|
|
141
|
-
* past this size it's both a memory hazard and unusably slow, so fail with
|
|
142
|
-
* a pointer to the streaming path instead of grinding on.
|
|
143
|
-
*/
|
|
144
|
-
const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
|
|
145
|
-
/**
|
|
146
|
-
* No policy size cap on transfers — a transfer is attempted whatever the
|
|
147
|
-
* size and fails with a concrete error when a real limit bites (device read
|
|
148
|
-
* error, stream timeout, disk). The streamed paths are disk-to-disk and
|
|
149
|
-
* never hold the file in daemon memory; only the chunked FALLBACK and
|
|
150
|
-
* readFileBytes (whose callers need a Buffer) are memory-bound.
|
|
151
|
-
*/
|
|
152
|
-
/**
|
|
153
|
-
* Where pulled device files land on the daemon host when no dest is given.
|
|
154
|
-
* Resolved lazily (not at module load) so a test that mocks `util/paths`
|
|
155
|
-
* doesn't hit its workspace binding before initialization.
|
|
156
|
-
*/
|
|
157
|
-
function pullDir(): string {
|
|
158
|
-
return resolve(dirs.workspace, "mesh-pull");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
103
|
export class MeshService {
|
|
162
104
|
private readonly waiters = new Set<() => void>();
|
|
163
105
|
private readonly transports = new Set<MeshTransport>();
|
|
@@ -176,14 +118,11 @@ export class MeshService {
|
|
|
176
118
|
* and one running ahead would have stale fixes accepted as fresh.
|
|
177
119
|
*/
|
|
178
120
|
private readonly receivedAt = new Map<string, number>();
|
|
179
|
-
/** One-time tokens arranging streamed (single-HTTP-request) transfers. */
|
|
180
|
-
private readonly transfers = new TransferStore();
|
|
181
|
-
/** One-time grants for bridge-served node installers. */
|
|
182
|
-
private readonly provision = new NodeProvisionStore();
|
|
183
|
-
/** One-time grants that hand a phone this bridge's connection details. */
|
|
184
|
-
private readonly companionPairs = new CompanionPairStore();
|
|
185
121
|
private readonly resolveNode: NodeBinaryResolver;
|
|
186
|
-
|
|
122
|
+
/** Chunked + streamed file transfer and the self-update flows. */
|
|
123
|
+
private readonly files: DeviceFiles;
|
|
124
|
+
/** Node provisioning, companion pairing, and bridge reachability. */
|
|
125
|
+
private readonly links: BridgeLinks;
|
|
187
126
|
private readonly freshFixTimeoutMs: number;
|
|
188
127
|
private readonly pollIntervalMs: number;
|
|
189
128
|
private readonly commandTimeoutMs: number;
|
|
@@ -199,11 +138,20 @@ export class MeshService {
|
|
|
199
138
|
this.commandTimeoutMs =
|
|
200
139
|
options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
201
140
|
this.resolveNode = options.nodeBinaryResolver ?? resolveNodeBinary;
|
|
141
|
+
this.files = new DeviceFiles({
|
|
142
|
+
load: () => this.load(),
|
|
143
|
+
resolveDevice: (query) => this.resolveDevice(query),
|
|
144
|
+
dispatchCommand: (query, name, params, timeoutMs) =>
|
|
145
|
+
this.dispatchCommand(query, name, params, timeoutMs),
|
|
146
|
+
commandTimeoutMs: this.commandTimeoutMs,
|
|
147
|
+
resolveNode: this.resolveNode,
|
|
148
|
+
});
|
|
149
|
+
this.links = new BridgeLinks(this.resolveNode);
|
|
202
150
|
}
|
|
203
151
|
|
|
204
152
|
/** The native bridge reports its reachable identity here (null on stop). */
|
|
205
153
|
setBridgeInfo(info: MeshBridgeInfo | null): void {
|
|
206
|
-
this.
|
|
154
|
+
this.links.setBridgeInfo(info);
|
|
207
155
|
}
|
|
208
156
|
|
|
209
157
|
/** Hydrate persisted devices/locations. Idempotent — safe to await from
|
|
@@ -604,11 +552,7 @@ export class MeshService {
|
|
|
604
552
|
return { ok: true, text: `${p} — ${fields.join(" · ")}` };
|
|
605
553
|
}
|
|
606
554
|
|
|
607
|
-
// ──
|
|
608
|
-
// The HTTP routes on the native bridge delegate here; the token is the
|
|
609
|
-
// entire authorization (single-use, device- and path-bound). `fromDeviceId`
|
|
610
|
-
// is the caller's self-declared identity — checked against the device the
|
|
611
|
-
// token was minted for, so a leaked token can't be redeemed by a peer.
|
|
555
|
+
// ── File transfer + self-update (see device-files.ts) ─────────────────────
|
|
612
556
|
|
|
613
557
|
/** POST /devices/file — a device streams a pull's file body up. */
|
|
614
558
|
acceptFileUpload(
|
|
@@ -616,7 +560,7 @@ export class MeshService {
|
|
|
616
560
|
body: Readable,
|
|
617
561
|
fromDeviceId?: string,
|
|
618
562
|
): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
|
|
619
|
-
return this.
|
|
563
|
+
return this.files.acceptFileUpload(token, body, fromDeviceId);
|
|
620
564
|
}
|
|
621
565
|
|
|
622
566
|
/** GET /devices/file — a device asks for a push's file body. */
|
|
@@ -624,815 +568,113 @@ export class MeshService {
|
|
|
624
568
|
token: string,
|
|
625
569
|
fromDeviceId?: string,
|
|
626
570
|
): Promise<{ path: string; size: number } | null> {
|
|
627
|
-
return this.
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** Streaming is per-command capability — old app builds fall back. */
|
|
631
|
-
private canStream(
|
|
632
|
-
target: DeviceInfo,
|
|
633
|
-
command: "upload_file" | "download_file",
|
|
634
|
-
): boolean {
|
|
635
|
-
return target.capabilities?.includes(command) ?? false;
|
|
571
|
+
return this.files.openFileDownload(token, fromDeviceId);
|
|
636
572
|
}
|
|
637
573
|
|
|
638
|
-
/**
|
|
639
|
-
|
|
640
|
-
* then the file body arrives as a single raw HTTP request, written
|
|
641
|
-
* atomically to `dest`. Resolves with the byte count.
|
|
642
|
-
*/
|
|
643
|
-
private async pullViaStream(
|
|
644
|
-
target: DeviceInfo,
|
|
645
|
-
remote: string,
|
|
646
|
-
dest: string,
|
|
647
|
-
): Promise<{ bytes: number } | { error: string }> {
|
|
648
|
-
const { token, done } = this.transfers.createPull(target.id, dest);
|
|
649
|
-
const dispatched = await this.dispatchCommand(
|
|
650
|
-
target.id,
|
|
651
|
-
"upload_file",
|
|
652
|
-
{ token, path: remote },
|
|
653
|
-
STREAM_TRANSFER_TIMEOUT_MS,
|
|
654
|
-
);
|
|
655
|
-
if ("error" in dispatched) {
|
|
656
|
-
this.transfers.cancel(token);
|
|
657
|
-
return { error: dispatched.error };
|
|
658
|
-
}
|
|
659
|
-
if (!dispatched.result.ok) {
|
|
660
|
-
this.transfers.cancel(token);
|
|
661
|
-
return {
|
|
662
|
-
error:
|
|
663
|
-
dispatched.result.message ??
|
|
664
|
-
`${target.name} could not upload ${remote}.`,
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
// The device answers the command AFTER its upload completes, so `done`
|
|
668
|
-
// is normally already resolved — the grace window only catches a device
|
|
669
|
-
// that claims success without having streamed anything.
|
|
670
|
-
try {
|
|
671
|
-
const bytes = await Promise.race([
|
|
672
|
-
done,
|
|
673
|
-
new Promise<never>((_, rej) =>
|
|
674
|
-
setTimeout(
|
|
675
|
-
() =>
|
|
676
|
-
rej(
|
|
677
|
-
new Error(
|
|
678
|
-
`${target.name} reported success but no upload arrived.`,
|
|
679
|
-
),
|
|
680
|
-
),
|
|
681
|
-
15_000,
|
|
682
|
-
).unref?.(),
|
|
683
|
-
),
|
|
684
|
-
]);
|
|
685
|
-
return { bytes };
|
|
686
|
-
} catch (err) {
|
|
687
|
-
this.transfers.cancel(token);
|
|
688
|
-
return { error: (err as Error).message };
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Raw file bytes off a device — the structured primitive under both the
|
|
694
|
-
* human-readable tool below and the native read/edit path (which must not
|
|
695
|
-
* have to parse a display envelope to recover the content).
|
|
696
|
-
*
|
|
697
|
-
* Small files ride the chunked command channel (one round trip). Files
|
|
698
|
-
* over the streaming threshold are pulled via the streaming path into a
|
|
699
|
-
* temp file first — the chunked channel pays a full mesh round trip per
|
|
700
|
-
* chunk and is far too slow for big payloads.
|
|
701
|
-
*/
|
|
702
|
-
async readFileBytes(
|
|
574
|
+
/** Raw file bytes off a device — the primitive under the read tools. */
|
|
575
|
+
readFileBytes(
|
|
703
576
|
query: unknown,
|
|
704
577
|
path: unknown,
|
|
705
578
|
): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
|
|
706
|
-
|
|
707
|
-
if (!p) return { error: "A file path is required." };
|
|
708
|
-
await this.load();
|
|
709
|
-
const resolved = this.resolveDevice(query);
|
|
710
|
-
if ("error" in resolved) return { error: resolved.error };
|
|
711
|
-
const target = resolved.target;
|
|
712
|
-
if (this.canStream(target, "upload_file")) {
|
|
713
|
-
const size = await this.statSize(target.id, p);
|
|
714
|
-
if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
|
|
715
|
-
const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
|
|
716
|
-
const pulled = await this.pullViaStream(target, p, tmp);
|
|
717
|
-
if ("error" in pulled) return { error: pulled.error };
|
|
718
|
-
try {
|
|
719
|
-
const data = await readFile(tmp);
|
|
720
|
-
return { data, deviceName: target.name };
|
|
721
|
-
} catch (err) {
|
|
722
|
-
return {
|
|
723
|
-
error: `Pulled ${p} but could not read the temp copy: ${(err as Error).message}`,
|
|
724
|
-
};
|
|
725
|
-
} finally {
|
|
726
|
-
await rm(tmp, { force: true }).catch(() => {});
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
return this.pullBytes(target.id, p);
|
|
579
|
+
return this.files.readFileBytes(query, path);
|
|
731
580
|
}
|
|
732
581
|
|
|
733
|
-
/**
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
path: string,
|
|
737
|
-
): Promise<number | undefined> {
|
|
738
|
-
const dispatched = await this.dispatchCommand(
|
|
739
|
-
deviceId,
|
|
740
|
-
"stat",
|
|
741
|
-
{ path },
|
|
742
|
-
FS_COMMAND_TIMEOUT_MS,
|
|
743
|
-
);
|
|
744
|
-
if ("error" in dispatched || !dispatched.result.ok) return undefined;
|
|
745
|
-
const size = dispatched.result.data?.size;
|
|
746
|
-
return typeof size === "number" ? size : undefined;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/** `device_read_file`: read a (text) file off the device, chunked. */
|
|
750
|
-
async readFileFromDevice(
|
|
751
|
-
query: unknown,
|
|
752
|
-
path: unknown,
|
|
753
|
-
): Promise<MeshToolResult> {
|
|
754
|
-
const p = requirePath(path);
|
|
755
|
-
if (!p) return { ok: false, text: "A file path is required." };
|
|
756
|
-
const buf = await this.readFileBytes(query, p);
|
|
757
|
-
if ("error" in buf) return { ok: false, text: buf.error };
|
|
758
|
-
return {
|
|
759
|
-
ok: true,
|
|
760
|
-
text: `${p} on ${buf.deviceName} (${formatBytes(buf.data.length)}):\n\n${buf.data.toString("utf8")}`,
|
|
761
|
-
};
|
|
582
|
+
/** `device_read_file`: read a (text) file off the device. */
|
|
583
|
+
readFileFromDevice(query: unknown, path: unknown): Promise<MeshToolResult> {
|
|
584
|
+
return this.files.readFileFromDevice(query, path);
|
|
762
585
|
}
|
|
763
586
|
|
|
764
587
|
/** `device_write_file`: write text content to a file on the device. */
|
|
765
|
-
|
|
588
|
+
writeFileToDevice(
|
|
766
589
|
query: unknown,
|
|
767
590
|
path: unknown,
|
|
768
591
|
content: unknown,
|
|
769
592
|
): Promise<MeshToolResult> {
|
|
770
|
-
|
|
771
|
-
if (!p) return { ok: false, text: "A file path is required." };
|
|
772
|
-
// A non-string body must fail, not silently truncate the target to an
|
|
773
|
-
// empty file (an empty string is a legitimate truncate-to-zero).
|
|
774
|
-
if (typeof content !== "string") {
|
|
775
|
-
return { ok: false, text: "File content must be a string." };
|
|
776
|
-
}
|
|
777
|
-
const written = await this.pushBytes(
|
|
778
|
-
query,
|
|
779
|
-
p,
|
|
780
|
-
Buffer.from(content, "utf8"),
|
|
781
|
-
);
|
|
782
|
-
if ("error" in written) return { ok: false, text: written.error };
|
|
783
|
-
return {
|
|
784
|
-
ok: true,
|
|
785
|
-
text: `Wrote ${formatBytes(written.bytes)} to ${p} on ${written.deviceName}.`,
|
|
786
|
-
};
|
|
593
|
+
return this.files.writeFileToDevice(query, path, content);
|
|
787
594
|
}
|
|
788
595
|
|
|
789
|
-
/** `device_pull_file`: copy a device file to the daemon host
|
|
790
|
-
|
|
791
|
-
* command-channel fallback otherwise. */
|
|
792
|
-
async pullFileFromDevice(
|
|
596
|
+
/** `device_pull_file`: copy a device file to the daemon host. */
|
|
597
|
+
pullFileFromDevice(
|
|
793
598
|
query: unknown,
|
|
794
599
|
remotePath: unknown,
|
|
795
600
|
localPath?: unknown,
|
|
796
601
|
): Promise<MeshToolResult> {
|
|
797
|
-
|
|
798
|
-
if (!remote) return { ok: false, text: "A remote file path is required." };
|
|
799
|
-
await this.load();
|
|
800
|
-
const resolved = this.resolveDevice(query);
|
|
801
|
-
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
802
|
-
const target = resolved.target;
|
|
803
|
-
const dest =
|
|
804
|
-
typeof localPath === "string" && localPath.trim()
|
|
805
|
-
? resolve(dirs.workspace, localPath.trim())
|
|
806
|
-
: resolve(
|
|
807
|
-
pullDir(),
|
|
808
|
-
`${target.name.replace(/\W+/g, "_")}-${basename(remote)}`,
|
|
809
|
-
);
|
|
810
|
-
if (this.canStream(target, "upload_file")) {
|
|
811
|
-
const started = Date.now();
|
|
812
|
-
const pulled = await this.pullViaStream(target, remote, dest);
|
|
813
|
-
if ("error" in pulled) return { ok: false, text: pulled.error };
|
|
814
|
-
return {
|
|
815
|
-
ok: true,
|
|
816
|
-
text: `Pulled ${formatBytes(pulled.bytes)} from ${remote} on ${target.name} → ${dest} (streamed, ${transferRate(pulled.bytes, started)})`,
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
const buf = await this.pullBytes(target.id, remote);
|
|
820
|
-
if ("error" in buf) return { ok: false, text: buf.error };
|
|
821
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
822
|
-
await writeFile(dest, buf.data);
|
|
823
|
-
return {
|
|
824
|
-
ok: true,
|
|
825
|
-
text: `Pulled ${formatBytes(buf.data.length)} from ${remote} on ${buf.deviceName} → ${dest} (chunked fallback — update the companion app for streamed transfers)`,
|
|
826
|
-
};
|
|
602
|
+
return this.files.pullFileFromDevice(query, remotePath, localPath);
|
|
827
603
|
}
|
|
828
604
|
|
|
829
|
-
/** `device_push_file`: copy a daemon-host file to the device
|
|
830
|
-
|
|
831
|
-
* chunked command-channel fallback otherwise. */
|
|
832
|
-
async pushFileToDevice(
|
|
605
|
+
/** `device_push_file`: copy a daemon-host file to the device. */
|
|
606
|
+
pushFileToDevice(
|
|
833
607
|
query: unknown,
|
|
834
608
|
localPath: unknown,
|
|
835
609
|
remotePath: unknown,
|
|
836
610
|
): Promise<MeshToolResult> {
|
|
837
|
-
|
|
838
|
-
if (!remote)
|
|
839
|
-
return { ok: false, text: "A remote destination path is required." };
|
|
840
|
-
const local =
|
|
841
|
-
typeof localPath === "string" && localPath.trim()
|
|
842
|
-
? resolve(dirs.workspace, localPath.trim())
|
|
843
|
-
: "";
|
|
844
|
-
if (!local) return { ok: false, text: "A local source path is required." };
|
|
845
|
-
await this.load();
|
|
846
|
-
const resolved = this.resolveDevice(query);
|
|
847
|
-
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
848
|
-
const target = resolved.target;
|
|
849
|
-
if (this.canStream(target, "download_file")) {
|
|
850
|
-
const started = Date.now();
|
|
851
|
-
const { token } = this.transfers.createPush(target.id, local);
|
|
852
|
-
const dispatched = await this.dispatchCommand(
|
|
853
|
-
target.id,
|
|
854
|
-
"download_file",
|
|
855
|
-
{ token, path: remote },
|
|
856
|
-
STREAM_TRANSFER_TIMEOUT_MS,
|
|
857
|
-
);
|
|
858
|
-
if ("error" in dispatched) {
|
|
859
|
-
this.transfers.cancel(token);
|
|
860
|
-
return { ok: false, text: dispatched.error };
|
|
861
|
-
}
|
|
862
|
-
if (!dispatched.result.ok) {
|
|
863
|
-
this.transfers.cancel(token);
|
|
864
|
-
return {
|
|
865
|
-
ok: false,
|
|
866
|
-
text:
|
|
867
|
-
dispatched.result.message ??
|
|
868
|
-
`${target.name} could not download ${local}.`,
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
const bytes = dispatched.result.data?.bytesWritten;
|
|
872
|
-
const size = typeof bytes === "number" ? bytes : 0;
|
|
873
|
-
return {
|
|
874
|
-
ok: true,
|
|
875
|
-
text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
let data: Buffer;
|
|
879
|
-
try {
|
|
880
|
-
data = await readFile(local);
|
|
881
|
-
} catch (err) {
|
|
882
|
-
// Includes Node's buffer-size ceiling (ERR_FS_FILE_TOO_LARGE) for
|
|
883
|
-
// files too big to hold in memory — surface the real reason.
|
|
884
|
-
return {
|
|
885
|
-
ok: false,
|
|
886
|
-
text: `Cannot read local file ${local}: ${(err as Error).message}`,
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
const written = await this.pushBytes(target.id, remote, data);
|
|
890
|
-
if ("error" in written) return { ok: false, text: written.error };
|
|
891
|
-
return {
|
|
892
|
-
ok: true,
|
|
893
|
-
text: `Pushed ${formatBytes(written.bytes)} to ${remote} on ${written.deviceName} (chunked fallback — update the companion app for streamed transfers).`,
|
|
894
|
-
};
|
|
611
|
+
return this.files.pushFileToDevice(query, localPath, remotePath);
|
|
895
612
|
}
|
|
896
613
|
|
|
897
|
-
/**
|
|
898
|
-
|
|
899
|
-
* to the device, then tells it to silently install (root or Shizuku) and
|
|
900
|
-
* restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
|
|
901
|
-
* the connection back on its own — the link drops only for the seconds the
|
|
902
|
-
* process is swapped, no manual reopen.
|
|
903
|
-
*
|
|
904
|
-
* The APK is hashed here and the digest travels with the install command;
|
|
905
|
-
* the device re-hashes the pushed file and refuses to install on a
|
|
906
|
-
* mismatch, so a truncated transfer can never be installed.
|
|
907
|
-
*/
|
|
908
|
-
async updateDeviceApp(
|
|
614
|
+
/** `update_device`: remote self-update for the companion app. */
|
|
615
|
+
updateDeviceApp(
|
|
909
616
|
query: unknown,
|
|
910
617
|
localApkPath: unknown,
|
|
911
618
|
remotePath?: unknown,
|
|
912
619
|
): Promise<MeshToolResult> {
|
|
913
|
-
|
|
914
|
-
typeof localApkPath === "string" && localApkPath.trim()
|
|
915
|
-
? resolve(dirs.workspace, localApkPath.trim())
|
|
916
|
-
: "";
|
|
917
|
-
if (!local) return { ok: false, text: "A local APK path is required." };
|
|
918
|
-
await this.load();
|
|
919
|
-
const resolved = this.resolveDevice(query);
|
|
920
|
-
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
921
|
-
const target = resolved.target;
|
|
922
|
-
if (target.capabilities && !target.capabilities.includes("install_apk")) {
|
|
923
|
-
return {
|
|
924
|
-
ok: false,
|
|
925
|
-
text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and root or Shizuku enabled (device control on).`,
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
let sha256: string;
|
|
930
|
-
let size: number;
|
|
931
|
-
try {
|
|
932
|
-
({ sha256, size } = await hashFile(local));
|
|
933
|
-
} catch (err) {
|
|
934
|
-
return {
|
|
935
|
-
ok: false,
|
|
936
|
-
text: `Cannot read APK ${local}: ${(err as Error).message}`,
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
if (size === 0) {
|
|
940
|
-
return { ok: false, text: `APK ${local} is empty.` };
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
const remote =
|
|
944
|
-
typeof remotePath === "string" && remotePath.trim()
|
|
945
|
-
? remotePath.trim()
|
|
946
|
-
: "/sdcard/Download/talon-companion-update.apk";
|
|
947
|
-
|
|
948
|
-
// 1. Stream the APK to the device.
|
|
949
|
-
const push = await this.pushFileToDevice(target.id, local, remote);
|
|
950
|
-
if (!push.ok) {
|
|
951
|
-
return { ok: false, text: `Update aborted — push failed: ${push.text}` };
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// 2. Trigger the silent install (device verifies the digest first).
|
|
955
|
-
const dispatched = await this.dispatchCommand(
|
|
956
|
-
target.id,
|
|
957
|
-
"install_apk",
|
|
958
|
-
{ path: remote, sha256 },
|
|
959
|
-
this.commandTimeoutMs,
|
|
960
|
-
);
|
|
961
|
-
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
962
|
-
if (!dispatched.result.ok) {
|
|
963
|
-
return {
|
|
964
|
-
ok: false,
|
|
965
|
-
text:
|
|
966
|
-
dispatched.result.message ?? `${target.name} refused the install.`,
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
return {
|
|
970
|
-
ok: true,
|
|
971
|
-
text:
|
|
972
|
-
`Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
|
|
973
|
-
`${dispatched.result.message ?? "Installing now."} ` +
|
|
974
|
-
`Confirm with get_device_status once it reconnects (appVersion should change).`,
|
|
975
|
-
};
|
|
620
|
+
return this.files.updateDeviceApp(query, localApkPath, remotePath);
|
|
976
621
|
}
|
|
977
622
|
|
|
978
|
-
/**
|
|
979
|
-
|
|
980
|
-
* replacement binary to the node, then sends `update_node` so the node
|
|
981
|
-
* verifies the digest, atomically swaps its own binary, and restarts into
|
|
982
|
-
* it (an in-place execve under systemd/launchd, so the mesh connection
|
|
983
|
-
* returns on its own within seconds — the same UX as the Android path).
|
|
984
|
-
*
|
|
985
|
-
* The binary is hashed here and the digest travels with the command; the
|
|
986
|
-
* node re-hashes the pushed file and refuses to swap on a mismatch, so a
|
|
987
|
-
* truncated transfer can never be installed.
|
|
988
|
-
*
|
|
989
|
-
* With no binary_path, the replacement is auto-resolved for the node's
|
|
990
|
-
* registered platform/arch (source build in a dev checkout, else the
|
|
991
|
-
* version-matched release download — see node-binaries.ts).
|
|
992
|
-
*/
|
|
993
|
-
async updateNodeBinary(
|
|
623
|
+
/** `update_node`: remote self-update for a headless talon-node. */
|
|
624
|
+
updateNodeBinary(
|
|
994
625
|
query: unknown,
|
|
995
626
|
localBinaryPath?: unknown,
|
|
996
627
|
remotePath?: unknown,
|
|
997
628
|
): Promise<MeshToolResult> {
|
|
998
|
-
|
|
999
|
-
const resolved = this.resolveDevice(query);
|
|
1000
|
-
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
1001
|
-
const target = resolved.target;
|
|
1002
|
-
if (target.capabilities && !target.capabilities.includes("update_node")) {
|
|
1003
|
-
return {
|
|
1004
|
-
ok: false,
|
|
1005
|
-
text: `${target.name} can't self-update — it needs the update_node capability (a talon-node headless device).`,
|
|
1006
|
-
};
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
let local: string;
|
|
1010
|
-
let provenance = "";
|
|
1011
|
-
if (typeof localBinaryPath === "string" && localBinaryPath.trim()) {
|
|
1012
|
-
local = resolve(dirs.workspace, localBinaryPath.trim());
|
|
1013
|
-
} else {
|
|
1014
|
-
const goos = platformToGoos(target.platform);
|
|
1015
|
-
if (!goos) {
|
|
1016
|
-
return {
|
|
1017
|
-
ok: false,
|
|
1018
|
-
text: `${target.name} is a ${target.platform} device — update_node targets headless nodes only.`,
|
|
1019
|
-
};
|
|
1020
|
-
}
|
|
1021
|
-
const goarch = normalizeGoarch(target.arch);
|
|
1022
|
-
if (!goarch) {
|
|
1023
|
-
return {
|
|
1024
|
-
ok: false,
|
|
1025
|
-
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.`,
|
|
1026
|
-
};
|
|
1027
|
-
}
|
|
1028
|
-
try {
|
|
1029
|
-
const bin = await this.resolveNode(goos, goarch);
|
|
1030
|
-
local = bin.path;
|
|
1031
|
-
provenance = ` (auto-resolved ${bin.version} for ${goos}/${goarch} via ${bin.source})`;
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
return { ok: false, text: (err as Error).message };
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
let sha256: string;
|
|
1038
|
-
let size: number;
|
|
1039
|
-
try {
|
|
1040
|
-
({ sha256, size } = await hashFile(local));
|
|
1041
|
-
} catch (err) {
|
|
1042
|
-
return {
|
|
1043
|
-
ok: false,
|
|
1044
|
-
text: `Cannot read binary ${local}: ${(err as Error).message}`,
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
if (size === 0) return { ok: false, text: `Binary ${local} is empty.` };
|
|
1048
|
-
|
|
1049
|
-
// Default staging path is /tmp on unix nodes (the node re-stages next to
|
|
1050
|
-
// its own executable before the atomic swap, so this is only transient).
|
|
1051
|
-
const remote =
|
|
1052
|
-
typeof remotePath === "string" && remotePath.trim()
|
|
1053
|
-
? remotePath.trim()
|
|
1054
|
-
: "/tmp/talon-node.update";
|
|
1055
|
-
|
|
1056
|
-
// 1. Stream the new binary to the node.
|
|
1057
|
-
const push = await this.pushFileToDevice(target.id, local, remote);
|
|
1058
|
-
if (!push.ok) {
|
|
1059
|
-
return { ok: false, text: `Update aborted — push failed: ${push.text}` };
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// 2. Trigger the swap + restart (node verifies the digest first).
|
|
1063
|
-
const dispatched = await this.dispatchCommand(
|
|
1064
|
-
target.id,
|
|
1065
|
-
"update_node",
|
|
1066
|
-
{ path: remote, sha256 },
|
|
1067
|
-
this.commandTimeoutMs,
|
|
1068
|
-
);
|
|
1069
|
-
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
1070
|
-
if (!dispatched.result.ok) {
|
|
1071
|
-
return {
|
|
1072
|
-
ok: false,
|
|
1073
|
-
text: dispatched.result.message ?? `${target.name} refused the update.`,
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
return {
|
|
1077
|
-
ok: true,
|
|
1078
|
-
text:
|
|
1079
|
-
`Pushed ${formatBytes(size)}${provenance} and staged the update on ${target.name}. ` +
|
|
1080
|
-
`${dispatched.result.message ?? "Restarting now."} ` +
|
|
1081
|
-
`Confirm with get_device_status once it reconnects (appVersion should change).`,
|
|
1082
|
-
};
|
|
629
|
+
return this.files.updateNodeBinary(query, localBinaryPath, remotePath);
|
|
1083
630
|
}
|
|
1084
631
|
|
|
1085
|
-
// ──
|
|
632
|
+
// ── Provisioning + pairing (see bridge-links.ts) ──────────────────────────
|
|
1086
633
|
|
|
1087
|
-
/**
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
* the digest-verified release download (cached under ~/.talon/node-bin).
|
|
1091
|
-
*/
|
|
1092
|
-
async getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
|
|
1093
|
-
const goos = normalizeGoos(os);
|
|
1094
|
-
const goarch = normalizeGoarch(arch);
|
|
1095
|
-
if (!goos || !goarch) {
|
|
1096
|
-
return { ok: false, text: unknownTargetText(os, arch) };
|
|
1097
|
-
}
|
|
1098
|
-
try {
|
|
1099
|
-
const bin = await this.resolveNode(goos, goarch);
|
|
1100
|
-
return {
|
|
1101
|
-
ok: true,
|
|
1102
|
-
text: `talon-node ${bin.version} for ${goos}/${goarch}: ${bin.path} (${formatBytes(bin.size)}, sha256 ${bin.sha256}, via ${bin.source})`,
|
|
1103
|
-
};
|
|
1104
|
-
} catch (err) {
|
|
1105
|
-
return { ok: false, text: (err as Error).message };
|
|
1106
|
-
}
|
|
634
|
+
/** `get_node_binary`: materialize a talon-node binary on the daemon host. */
|
|
635
|
+
getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
|
|
636
|
+
return this.links.getNodeBinary(os, arch);
|
|
1107
637
|
}
|
|
1108
638
|
|
|
1109
|
-
/**
|
|
1110
|
-
|
|
1111
|
-
* bridge and return the one command that turns a fresh host into a mesh
|
|
1112
|
-
* node — it fetches the installer script, which downloads the (digest-
|
|
1113
|
-
* verified) binary from the same bridge, installs it, pre-pins the bridge
|
|
1114
|
-
* certificate, and registers the boot service.
|
|
1115
|
-
*/
|
|
1116
|
-
async makeNodeInstallLink(
|
|
639
|
+
/** `make_node_install_link`: mint a single-use node provisioning URL. */
|
|
640
|
+
makeNodeInstallLink(
|
|
1117
641
|
os: unknown,
|
|
1118
642
|
arch: unknown,
|
|
1119
643
|
name?: unknown,
|
|
1120
644
|
bridgeUrl?: unknown,
|
|
1121
645
|
): Promise<MeshToolResult> {
|
|
1122
|
-
|
|
1123
|
-
const goarch = normalizeGoarch(arch);
|
|
1124
|
-
if (!goos || !goarch) {
|
|
1125
|
-
return { ok: false, text: unknownTargetText(os, arch) };
|
|
1126
|
-
}
|
|
1127
|
-
const info = this.bridgeInfo;
|
|
1128
|
-
if (!info) {
|
|
1129
|
-
return {
|
|
1130
|
-
ok: false,
|
|
1131
|
-
text: "The native bridge isn't running, so there is nothing for a new node to connect to. Enable the native frontend first.",
|
|
1132
|
-
};
|
|
1133
|
-
}
|
|
1134
|
-
if (!info.token) {
|
|
1135
|
-
return {
|
|
1136
|
-
ok: false,
|
|
1137
|
-
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.",
|
|
1138
|
-
};
|
|
1139
|
-
}
|
|
1140
|
-
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
1141
|
-
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1142
|
-
let bin;
|
|
1143
|
-
try {
|
|
1144
|
-
bin = await this.resolveNode(goos, goarch);
|
|
1145
|
-
} catch (err) {
|
|
1146
|
-
return { ok: false, text: (err as Error).message };
|
|
1147
|
-
}
|
|
1148
|
-
const grant = this.provision.create({
|
|
1149
|
-
goos,
|
|
1150
|
-
goarch,
|
|
1151
|
-
...(typeof name === "string" && name.trim() ? { name: name.trim() } : {}),
|
|
1152
|
-
binaryPath: bin.path,
|
|
1153
|
-
sha256: bin.sha256,
|
|
1154
|
-
size: bin.size,
|
|
1155
|
-
version: bin.version,
|
|
1156
|
-
bridgeUrl: base,
|
|
1157
|
-
bearerToken: info.token,
|
|
1158
|
-
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1159
|
-
});
|
|
1160
|
-
return {
|
|
1161
|
-
ok: true,
|
|
1162
|
-
text: [
|
|
1163
|
-
`Run this on the new ${goos}/${goarch} host:`,
|
|
1164
|
-
"",
|
|
1165
|
-
` ${installOneLiner(grant)}`,
|
|
1166
|
-
"",
|
|
1167
|
-
`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.`,
|
|
1168
|
-
`Single-use link, expires in 30 minutes. The host must be able to reach ${base}.`,
|
|
1169
|
-
].join("\n"),
|
|
1170
|
-
};
|
|
646
|
+
return this.links.makeNodeInstallLink(os, arch, name, bridgeUrl);
|
|
1171
647
|
}
|
|
1172
648
|
|
|
1173
|
-
/**
|
|
1174
|
-
* Mint a single-use link that connects a phone to this bridge without
|
|
1175
|
-
* anyone typing an address or a token — the companion half of
|
|
1176
|
-
* {@link makeNodeInstallLink}.
|
|
1177
|
-
*
|
|
1178
|
-
* Returns the link plus the values it carries, so a caller (a `/mesh`
|
|
1179
|
-
* reply, say) can print both: the link is the one-tap path, the values are
|
|
1180
|
-
* what someone falls back to when the app isn't installed yet.
|
|
1181
|
-
*/
|
|
649
|
+
/** Mint a single-use link that connects a phone to this bridge. */
|
|
1182
650
|
makeCompanionPairLink(
|
|
1183
651
|
label?: unknown,
|
|
1184
652
|
bridgeUrl?: unknown,
|
|
1185
|
-
):
|
|
1186
|
-
|
|
1187
|
-
ok: true;
|
|
1188
|
-
link: string;
|
|
1189
|
-
url: string;
|
|
1190
|
-
token: string;
|
|
1191
|
-
fingerprint?: string;
|
|
1192
|
-
}
|
|
1193
|
-
| { ok: false; text: string } {
|
|
1194
|
-
const info = this.bridgeInfo;
|
|
1195
|
-
if (!info) {
|
|
1196
|
-
return {
|
|
1197
|
-
ok: false,
|
|
1198
|
-
text: "The native bridge isn't running, so there is nothing for a phone to connect to. Enable the native frontend first.",
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
if (!info.token) {
|
|
1202
|
-
return {
|
|
1203
|
-
ok: false,
|
|
1204
|
-
text: "The bridge has no bearer token (loopback-only bind), and companions authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
|
|
1205
|
-
};
|
|
1206
|
-
}
|
|
1207
|
-
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
1208
|
-
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1209
|
-
const grant = this.companionPairs.create({
|
|
1210
|
-
bridgeUrl: base,
|
|
1211
|
-
bearerToken: info.token,
|
|
1212
|
-
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1213
|
-
...(typeof label === "string" && label.trim()
|
|
1214
|
-
? { label: label.trim() }
|
|
1215
|
-
: {}),
|
|
1216
|
-
});
|
|
1217
|
-
return {
|
|
1218
|
-
ok: true,
|
|
1219
|
-
link: pairLink(grant),
|
|
1220
|
-
url: base,
|
|
1221
|
-
token: info.token,
|
|
1222
|
-
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1223
|
-
};
|
|
653
|
+
): ReturnType<BridgeLinks["makeCompanionPairLink"]> {
|
|
654
|
+
return this.links.makeCompanionPairLink(label, bridgeUrl);
|
|
1224
655
|
}
|
|
1225
656
|
|
|
1226
|
-
/**
|
|
1227
|
-
* GET /pair — serve a pairing grant (single-use), as the landing page or
|
|
1228
|
-
* as the JSON the companion reads.
|
|
1229
|
-
*/
|
|
657
|
+
/** GET /pair — serve a pairing grant (single-use). */
|
|
1230
658
|
openCompanionPair(
|
|
1231
659
|
token: string,
|
|
1232
660
|
format: "html" | "json",
|
|
1233
661
|
): { contentType: string; body: string } | null {
|
|
1234
|
-
|
|
1235
|
-
if (!payload) return null;
|
|
1236
|
-
return format === "json"
|
|
1237
|
-
? {
|
|
1238
|
-
contentType: "application/json; charset=utf-8",
|
|
1239
|
-
body: JSON.stringify(payload satisfies CompanionPairPayload),
|
|
1240
|
-
}
|
|
1241
|
-
: {
|
|
1242
|
-
contentType: "text/html; charset=utf-8",
|
|
1243
|
-
body: pairPage(payload),
|
|
1244
|
-
};
|
|
662
|
+
return this.links.openCompanionPair(token, format);
|
|
1245
663
|
}
|
|
1246
664
|
|
|
1247
|
-
/**
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
*
|
|
1251
|
-
* The bearer token comes back with it. Deciding who may see a credential is
|
|
1252
|
-
* the caller's job, not this seam's — the Telegram `/mesh` shows it to the
|
|
1253
|
-
* configured admin and withholds it from everyone else — and an operator who
|
|
1254
|
-
* asked their own daemon for its own connection details should get an
|
|
1255
|
-
* answer, not a lecture.
|
|
1256
|
-
*/
|
|
1257
|
-
bridgeReachability():
|
|
1258
|
-
| {
|
|
1259
|
-
ok: true;
|
|
1260
|
-
url: string;
|
|
1261
|
-
authRequired: boolean;
|
|
1262
|
-
token?: string;
|
|
1263
|
-
fingerprint?: string;
|
|
1264
|
-
}
|
|
1265
|
-
| { ok: false; text: string } {
|
|
1266
|
-
const info = this.bridgeInfo;
|
|
1267
|
-
if (!info) return { ok: false, text: "The native bridge isn't running." };
|
|
1268
|
-
const base = this.bridgeBaseUrl(info);
|
|
1269
|
-
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1270
|
-
return {
|
|
1271
|
-
ok: true,
|
|
1272
|
-
url: base,
|
|
1273
|
-
authRequired: Boolean(info.token),
|
|
1274
|
-
...(info.token ? { token: info.token } : {}),
|
|
1275
|
-
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1276
|
-
};
|
|
665
|
+
/** How this bridge is reachable, for an operator asking where to point a device. */
|
|
666
|
+
bridgeReachability(): ReturnType<BridgeLinks["bridgeReachability"]> {
|
|
667
|
+
return this.links.bridgeReachability();
|
|
1277
668
|
}
|
|
1278
669
|
|
|
1279
670
|
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
1280
671
|
openNodeInstall(token: string): { script: string; filename: string } | null {
|
|
1281
|
-
return this.
|
|
672
|
+
return this.links.openNodeInstall(token);
|
|
1282
673
|
}
|
|
1283
674
|
|
|
1284
675
|
/** GET /node/binary — serve a grant's binary (single-use). */
|
|
1285
676
|
openNodeBinary(token: string): { path: string; size: number } | null {
|
|
1286
|
-
return this.
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
/**
|
|
1290
|
-
* The bridge base URL a NEW host should dial: an explicit override wins;
|
|
1291
|
-
* otherwise derive from the bridge's bind. A wildcard bind maps to this
|
|
1292
|
-
* host's first external IPv4; a loopback bind is unreachable from other
|
|
1293
|
-
* machines, so it's an error rather than a link that can't work.
|
|
1294
|
-
*/
|
|
1295
|
-
private bridgeBaseUrl(
|
|
1296
|
-
info: MeshBridgeInfo,
|
|
1297
|
-
explicit?: unknown,
|
|
1298
|
-
): string | { error: string } {
|
|
1299
|
-
if (typeof explicit === "string" && explicit.trim()) {
|
|
1300
|
-
const url = explicit.trim().replace(/\/+$/, "");
|
|
1301
|
-
if (!/^https?:\/\/\S+$/.test(url)) {
|
|
1302
|
-
return { error: `bridge_url must be an http(s) URL, got "${url}".` };
|
|
1303
|
-
}
|
|
1304
|
-
return url;
|
|
1305
|
-
}
|
|
1306
|
-
let host = info.host;
|
|
1307
|
-
if (host === "0.0.0.0" || host === "::") {
|
|
1308
|
-
const external = firstExternalIPv4();
|
|
1309
|
-
if (!external) {
|
|
1310
|
-
return {
|
|
1311
|
-
error:
|
|
1312
|
-
"Could not determine this host's external address — pass bridge_url explicitly (the URL the new node should dial).",
|
|
1313
|
-
};
|
|
1314
|
-
}
|
|
1315
|
-
host = external;
|
|
1316
|
-
} else if (isLoopbackAddress(host)) {
|
|
1317
|
-
return {
|
|
1318
|
-
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.`,
|
|
1319
|
-
};
|
|
1320
|
-
}
|
|
1321
|
-
return `${info.scheme}://${host}:${info.port}`;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/**
|
|
1325
|
-
* Chunked read of a remote file into a Buffer. Loops `read_file` with
|
|
1326
|
-
* increasing offsets until the device reports EOF.
|
|
1327
|
-
*
|
|
1328
|
-
* End-of-file is the DEVICE's call (`eof: true`), never inferred from a
|
|
1329
|
-
* short chunk: devices cap their chunk size (the companion serves at most
|
|
1330
|
-
* 256KB per read regardless of the requested length), so a chunk shorter
|
|
1331
|
-
* than the request is normal mid-file and treating it as EOF silently
|
|
1332
|
-
* truncated every chunked read past the device's cap. A zero-length chunk
|
|
1333
|
-
* without `eof` is a stuck transfer and fails loudly instead of looping.
|
|
1334
|
-
*/
|
|
1335
|
-
private async pullBytes(
|
|
1336
|
-
query: unknown,
|
|
1337
|
-
path: string,
|
|
1338
|
-
): Promise<{ data: Buffer; deviceName: string } | { error: string }> {
|
|
1339
|
-
const chunks: Buffer[] = [];
|
|
1340
|
-
let offset = 0;
|
|
1341
|
-
let deviceName = "device";
|
|
1342
|
-
for (;;) {
|
|
1343
|
-
const dispatched = await this.dispatchCommand(
|
|
1344
|
-
query,
|
|
1345
|
-
"read_file",
|
|
1346
|
-
{ path, offset, len: FILE_CHUNK_BYTES },
|
|
1347
|
-
FS_COMMAND_TIMEOUT_MS,
|
|
1348
|
-
);
|
|
1349
|
-
if ("error" in dispatched) {
|
|
1350
|
-
return {
|
|
1351
|
-
error:
|
|
1352
|
-
offset > 0
|
|
1353
|
-
? `${dispatched.error} (transfer of ${path} aborted after ${formatBytes(offset)})`
|
|
1354
|
-
: dispatched.error,
|
|
1355
|
-
};
|
|
1356
|
-
}
|
|
1357
|
-
deviceName = dispatched.target.name;
|
|
1358
|
-
const { result } = dispatched;
|
|
1359
|
-
if (!result.ok) {
|
|
1360
|
-
const reason = result.message ?? `Could not read ${path}.`;
|
|
1361
|
-
return {
|
|
1362
|
-
error:
|
|
1363
|
-
offset > 0
|
|
1364
|
-
? `${reason} (transfer aborted after ${formatBytes(offset)})`
|
|
1365
|
-
: reason,
|
|
1366
|
-
};
|
|
1367
|
-
}
|
|
1368
|
-
const b64 =
|
|
1369
|
-
typeof result.data?.base64 === "string" ? result.data.base64 : "";
|
|
1370
|
-
const chunk = Buffer.from(b64, "base64");
|
|
1371
|
-
chunks.push(chunk);
|
|
1372
|
-
offset += chunk.length;
|
|
1373
|
-
if (result.data?.eof === true) break;
|
|
1374
|
-
if (chunk.length === 0) {
|
|
1375
|
-
return {
|
|
1376
|
-
error: `${path} transfer stalled: the device returned an empty chunk without reporting end-of-file (after ${formatBytes(offset)}).`,
|
|
1377
|
-
};
|
|
1378
|
-
}
|
|
1379
|
-
if (offset > MAX_CHUNKED_TRANSFER_BYTES) {
|
|
1380
|
-
return {
|
|
1381
|
-
error: `${path} exceeds the ${formatBytes(MAX_CHUNKED_TRANSFER_BYTES)} chunked-transfer limit (device never reported end-of-file after ${formatBytes(offset)}). Use device_pull_file with a streaming-capable companion build for large files.`,
|
|
1382
|
-
};
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
try {
|
|
1386
|
-
return { data: Buffer.concat(chunks), deviceName };
|
|
1387
|
-
} catch (err) {
|
|
1388
|
-
// Node buffer ceiling / out of memory — the one real size limit left.
|
|
1389
|
-
return {
|
|
1390
|
-
error: `${path} transferred ${formatBytes(offset)} but is too large to assemble in daemon memory: ${(err as Error).message}`,
|
|
1391
|
-
};
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
/**
|
|
1396
|
-
* Chunked write of a Buffer to a remote file. The first chunk truncates the
|
|
1397
|
-
* target; subsequent chunks append at their offset.
|
|
1398
|
-
*/
|
|
1399
|
-
private async pushBytes(
|
|
1400
|
-
query: unknown,
|
|
1401
|
-
path: string,
|
|
1402
|
-
data: Buffer,
|
|
1403
|
-
): Promise<{ bytes: number; deviceName: string } | { error: string }> {
|
|
1404
|
-
let offset = 0;
|
|
1405
|
-
let deviceName = "device";
|
|
1406
|
-
// On a mid-transfer failure the device is left with a partial file —
|
|
1407
|
-
// say so, with how far the transfer got, so the state isn't a mystery.
|
|
1408
|
-
const partial = (reason: string): { error: string } => ({
|
|
1409
|
-
error:
|
|
1410
|
-
offset > 0
|
|
1411
|
-
? `${reason} (upload aborted — ${path} on the device is a ${formatBytes(offset)} partial write of ${formatBytes(data.length)})`
|
|
1412
|
-
: reason,
|
|
1413
|
-
});
|
|
1414
|
-
// A zero-length write still needs one call to create/truncate the file.
|
|
1415
|
-
do {
|
|
1416
|
-
const chunk = data.subarray(offset, offset + FILE_CHUNK_BYTES);
|
|
1417
|
-
const dispatched = await this.dispatchCommand(
|
|
1418
|
-
query,
|
|
1419
|
-
"write_file",
|
|
1420
|
-
{
|
|
1421
|
-
path,
|
|
1422
|
-
base64: chunk.toString("base64"),
|
|
1423
|
-
offset,
|
|
1424
|
-
truncate: offset === 0,
|
|
1425
|
-
},
|
|
1426
|
-
FS_COMMAND_TIMEOUT_MS,
|
|
1427
|
-
);
|
|
1428
|
-
if ("error" in dispatched) return partial(dispatched.error);
|
|
1429
|
-
deviceName = dispatched.target.name;
|
|
1430
|
-
if (!dispatched.result.ok) {
|
|
1431
|
-
return partial(dispatched.result.message ?? `Could not write ${path}.`);
|
|
1432
|
-
}
|
|
1433
|
-
offset += chunk.length;
|
|
1434
|
-
} while (offset < data.length);
|
|
1435
|
-
return { bytes: data.length, deviceName };
|
|
677
|
+
return this.links.openNodeBinary(token);
|
|
1436
678
|
}
|
|
1437
679
|
|
|
1438
680
|
/**
|
|
@@ -1747,15 +989,6 @@ function sampleEvenly<T>(items: T[], max: number): T[] {
|
|
|
1747
989
|
return out;
|
|
1748
990
|
}
|
|
1749
991
|
|
|
1750
|
-
function age(ms: number): string {
|
|
1751
|
-
const sec = Math.max(0, Math.round(ms / 1000));
|
|
1752
|
-
if (sec < 60) return `${sec}s ago`;
|
|
1753
|
-
const min = Math.round(sec / 60);
|
|
1754
|
-
if (min < 60) return `${min}m ago`;
|
|
1755
|
-
const hrs = Math.round(min / 60);
|
|
1756
|
-
return `${hrs}h ago`;
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
992
|
/** Clamp a caller-supplied exec timeout (seconds) to the allowed window. */
|
|
1760
993
|
function clampExecTimeout(value: unknown): number {
|
|
1761
994
|
const sec = typeof value === "number" ? value : Number(value);
|
|
@@ -1766,33 +999,6 @@ function clampExecTimeout(value: unknown): number {
|
|
|
1766
999
|
);
|
|
1767
1000
|
}
|
|
1768
1001
|
|
|
1769
|
-
/** Trim a string path param, returning undefined when blank. */
|
|
1770
|
-
function requirePath(value: unknown): string | undefined {
|
|
1771
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
/** Error text for an os/arch pair outside the talon-node build matrix. */
|
|
1775
|
-
function unknownTargetText(os: unknown, arch: unknown): string {
|
|
1776
|
-
return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
|
|
1777
|
-
(t) => `${t.goos}/${t.goarch}`,
|
|
1778
|
-
).join(", ")} (macos ≡ darwin, x86_64 ≡ amd64, aarch64 ≡ arm64).`;
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
/** First non-internal IPv4 on this host — the wildcard-bind fallback. */
|
|
1782
|
-
function firstExternalIPv4(): string | undefined {
|
|
1783
|
-
for (const list of Object.values(networkInterfaces())) {
|
|
1784
|
-
for (const iface of list ?? []) {
|
|
1785
|
-
if (!iface.internal && iface.family === "IPv4") return iface.address;
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
return undefined;
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
function isLoopbackAddress(host: string): boolean {
|
|
1792
|
-
const h = host.toLowerCase();
|
|
1793
|
-
return h === "localhost" || h === "::1" || h.startsWith("127.");
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
1002
|
/** Render an exec result: exit code headline + stdout/stderr blocks. */
|
|
1797
1003
|
function formatExecResult(
|
|
1798
1004
|
device: DeviceInfo,
|
|
@@ -1819,34 +1025,6 @@ function formatExecResult(
|
|
|
1819
1025
|
return parts.join("\n");
|
|
1820
1026
|
}
|
|
1821
1027
|
|
|
1822
|
-
/** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
|
|
1823
|
-
function transferRate(bytes: number, startedAtMs: number): string {
|
|
1824
|
-
const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
|
|
1825
|
-
return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
function formatBytes(bytes: number): string {
|
|
1829
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
1830
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1831
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
/** Stream a file through SHA-256 without loading it into memory (APKs are big
|
|
1835
|
-
* and Buffer has a hard ceiling). Returns the hex digest and byte size. */
|
|
1836
|
-
async function hashFile(
|
|
1837
|
-
path: string,
|
|
1838
|
-
): Promise<{ sha256: string; size: number }> {
|
|
1839
|
-
const { size } = await stat(path);
|
|
1840
|
-
const hash = createHash("sha256");
|
|
1841
|
-
await new Promise<void>((resolve, reject) => {
|
|
1842
|
-
createReadStream(path)
|
|
1843
|
-
.on("data", (chunk) => hash.update(chunk))
|
|
1844
|
-
.on("end", () => resolve())
|
|
1845
|
-
.on("error", reject);
|
|
1846
|
-
});
|
|
1847
|
-
return { sha256: hash.digest("hex"), size };
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
1028
|
// ── Process-wide instance ─────────────────────────────────────────────────────
|
|
1851
1029
|
|
|
1852
1030
|
let instance: MeshService | null = null;
|