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.
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/commands/mesh-audit.ts +89 -0
- package/src/cli/commands/mesh.ts +22 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/status.ts +24 -1
- package/src/core/config/index.ts +12 -11
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +4 -4
- package/src/core/engine/gateway-actions/fetch-url/index.ts +4 -3
- package/src/core/engine/gateway-actions/mesh.ts +2 -0
- package/src/core/engine/gateway-routes.ts +16 -0
- package/src/core/mesh/audit.ts +232 -0
- package/src/core/mesh/credentials/index.ts +1 -0
- package/src/core/mesh/credentials/store.ts +42 -1
- package/src/core/mesh/credentials/types.ts +21 -2
- package/src/core/mesh/devices/service.ts +103 -5
- package/src/core/mesh/links/bridge-links.ts +94 -9
- package/src/core/mesh/links/node-provision.ts +285 -41
- package/src/core/mesh/transfers/device-files.ts +110 -39
- package/src/core/mesh/transfers/transfers.ts +31 -15
- package/src/core/tools/ops/mesh.ts +24 -4
- package/src/core/weaver/weaver.ts +4 -1
- package/src/frontend/native/bridge/credentials/upgrade.ts +16 -3
- package/src/frontend/native/bridge/routes/host.ts +12 -2
- package/src/frontend/native/bridge/routes/pre-auth.ts +8 -2
- package/src/frontend/native/bridge/server.ts +8 -0
- package/src/frontend/native/bridge/tls.ts +15 -0
- package/src/frontend/native/index.ts +26 -0
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/util/logging/turn-scope.ts +23 -2
|
@@ -253,7 +253,11 @@ export class DeviceCredentialStore {
|
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
255
|
const records = this.activeRecordsFor(deviceId);
|
|
256
|
-
|
|
256
|
+
const at = this.now();
|
|
257
|
+
for (const record of records) {
|
|
258
|
+
record.scopes = next;
|
|
259
|
+
record.scopesSetAt = at;
|
|
260
|
+
}
|
|
257
261
|
if (records.length > 0) {
|
|
258
262
|
await this.persist();
|
|
259
263
|
this.emit(records.map((r) => r.id));
|
|
@@ -261,6 +265,29 @@ export class DeviceCredentialStore {
|
|
|
261
265
|
return records.map(publicView);
|
|
262
266
|
}
|
|
263
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Move every live credential still holding exactly `from` to `to`, unless
|
|
270
|
+
* its scopes were set by hand — for when a default widens, so devices
|
|
271
|
+
* issued the old default get the new one. Returns how many changed.
|
|
272
|
+
*/
|
|
273
|
+
async adoptDefaultScopes(
|
|
274
|
+
from: readonly MeshScope[],
|
|
275
|
+
to: readonly MeshScope[],
|
|
276
|
+
): Promise<number> {
|
|
277
|
+
await this.load();
|
|
278
|
+
const was = normalizeScopes(from).join(",");
|
|
279
|
+
const next = normalizeScopes(to);
|
|
280
|
+
let changed = 0;
|
|
281
|
+
for (const record of this.records.values()) {
|
|
282
|
+
if (!this.isActive(record) || record.scopesSetAt !== undefined) continue;
|
|
283
|
+
if (record.scopes.join(",") !== was) continue;
|
|
284
|
+
record.scopes = [...next];
|
|
285
|
+
changed++;
|
|
286
|
+
}
|
|
287
|
+
if (changed > 0) await this.persist();
|
|
288
|
+
return changed;
|
|
289
|
+
}
|
|
290
|
+
|
|
264
291
|
/** Subscribe to revocations (the bridge drops matching sessions). */
|
|
265
292
|
onRevoked(listener: RevocationListener): () => void {
|
|
266
293
|
this.listeners.add(listener);
|
|
@@ -313,11 +340,13 @@ export class DeviceCredentialStore {
|
|
|
313
340
|
}
|
|
314
341
|
const minted = mintCredentialToken();
|
|
315
342
|
const at = this.now();
|
|
343
|
+
const scopesSetAt = this.handSetScopes(input.deviceId, scopes);
|
|
316
344
|
const record: DeviceCredentialRecord = {
|
|
317
345
|
id: minted.id,
|
|
318
346
|
deviceId: input.deviceId,
|
|
319
347
|
tokenHash: minted.tokenHash,
|
|
320
348
|
scopes,
|
|
349
|
+
...(scopesSetAt !== undefined ? { scopesSetAt } : {}),
|
|
321
350
|
origin: input.origin,
|
|
322
351
|
createdAt: at,
|
|
323
352
|
...(input.deviceId === null ? { expiresAt: at + UNBOUND_TTL_MS } : {}),
|
|
@@ -327,6 +356,18 @@ export class DeviceCredentialStore {
|
|
|
327
356
|
return { token: minted.token, credential: publicView(record) };
|
|
328
357
|
}
|
|
329
358
|
|
|
359
|
+
/** A re-issue keeps the mark of scopes an operator set by hand. */
|
|
360
|
+
private handSetScopes(
|
|
361
|
+
deviceId: string | null,
|
|
362
|
+
scopes: readonly MeshScope[],
|
|
363
|
+
): number | undefined {
|
|
364
|
+
if (deviceId === null) return undefined;
|
|
365
|
+
const key = scopes.join(",");
|
|
366
|
+
return this.activeRecordsFor(deviceId).find(
|
|
367
|
+
(r) => r.scopesSetAt !== undefined && r.scopes.join(",") === key,
|
|
368
|
+
)?.scopesSetAt;
|
|
369
|
+
}
|
|
370
|
+
|
|
330
371
|
private isActive(record: DeviceCredentialRecord): boolean {
|
|
331
372
|
if (record.revokedAt !== undefined) return false;
|
|
332
373
|
return record.expiresAt === undefined || this.now() < record.expiresAt;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the secret plus the metadata below; the plaintext exists once, in the reply
|
|
8
8
|
* or link that hands it to the device.
|
|
9
9
|
*
|
|
10
|
-
* Scopes (
|
|
10
|
+
* Scopes (see docs/mesh-credentials.md):
|
|
11
11
|
*
|
|
12
12
|
* device register/heartbeat/report as ITSELF, receive and answer its
|
|
13
13
|
* own commands, move the files the daemon asked it to move.
|
|
@@ -22,10 +22,24 @@
|
|
|
22
22
|
const MESH_SCOPES = ["device", "client", "operator"] as const;
|
|
23
23
|
export type MeshScope = (typeof MESH_SCOPES)[number];
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* What a paired or upgraded companion gets unless the operator narrows it
|
|
27
|
+
* (`native.companionScopes`): everything, as with the shared token.
|
|
28
|
+
*/
|
|
26
29
|
export const DEFAULT_COMPANION_SCOPES: readonly MeshScope[] = [
|
|
27
30
|
"device",
|
|
28
31
|
"client",
|
|
32
|
+
"operator",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The companion default before `operator` joined it. Credentials still
|
|
37
|
+
* holding exactly this (and never set by hand) are moved to the current
|
|
38
|
+
* default on startup — see `DeviceCredentialStore.adoptDefaultScopes`.
|
|
39
|
+
*/
|
|
40
|
+
export const FORMER_COMPANION_SCOPES: readonly MeshScope[] = [
|
|
41
|
+
"device",
|
|
42
|
+
"client",
|
|
29
43
|
];
|
|
30
44
|
|
|
31
45
|
/** A headless talon-node is a device and nothing else. */
|
|
@@ -55,6 +69,11 @@ export type DeviceCredentialRecord = {
|
|
|
55
69
|
/** Hex SHA-256 of the whole token. */
|
|
56
70
|
tokenHash: string;
|
|
57
71
|
scopes: MeshScope[];
|
|
72
|
+
/**
|
|
73
|
+
* When an operator last set this device's scopes by hand (`talon mesh
|
|
74
|
+
* scopes`). Such a credential keeps its scopes when a default changes.
|
|
75
|
+
*/
|
|
76
|
+
scopesSetAt?: number;
|
|
58
77
|
origin: CredentialOrigin;
|
|
59
78
|
createdAt: number;
|
|
60
79
|
lastUsedAt?: number;
|
|
@@ -50,6 +50,15 @@ import {
|
|
|
50
50
|
type NodeBinaryResolver,
|
|
51
51
|
} from "../links/node-binaries.js";
|
|
52
52
|
import { MeshRegistry } from "./registry.js";
|
|
53
|
+
import {
|
|
54
|
+
auditErrorText,
|
|
55
|
+
auditIssuer,
|
|
56
|
+
hashCommandArgs,
|
|
57
|
+
MeshAuditLog,
|
|
58
|
+
type MeshAuditEntry,
|
|
59
|
+
type MeshAuditQuery,
|
|
60
|
+
} from "../audit.js";
|
|
61
|
+
import { logWarn } from "../../../util/log.js";
|
|
53
62
|
import {
|
|
54
63
|
DeviceCredentialStore,
|
|
55
64
|
type CredentialAdminContext,
|
|
@@ -97,6 +106,12 @@ export type MeshServiceOptions = {
|
|
|
97
106
|
* without one (tests, embedders) links carry the shared bridge token.
|
|
98
107
|
*/
|
|
99
108
|
credentials?: DeviceCredentialStore;
|
|
109
|
+
/**
|
|
110
|
+
* Where every dispatched command is recorded (who, which device, which
|
|
111
|
+
* command, an args hash, outcome, duration). Absent (tests, embedders) =
|
|
112
|
+
* nothing is recorded.
|
|
113
|
+
*/
|
|
114
|
+
audit?: MeshAuditLog;
|
|
100
115
|
};
|
|
101
116
|
|
|
102
117
|
const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
|
|
@@ -143,6 +158,8 @@ export class MeshService {
|
|
|
143
158
|
private loading: Promise<void> | null = null;
|
|
144
159
|
/** Per-device credentials (null = shared-token-only mesh). */
|
|
145
160
|
readonly credentials: DeviceCredentialStore | null;
|
|
161
|
+
/** The command audit (null = commands are not recorded). */
|
|
162
|
+
private readonly audit: MeshAuditLog | null;
|
|
146
163
|
/** `native.legacySharedToken` as the bridge last reported it. */
|
|
147
164
|
private legacySharedToken = true;
|
|
148
165
|
|
|
@@ -167,6 +184,7 @@ export class MeshService {
|
|
|
167
184
|
resolveNode: this.resolveNode,
|
|
168
185
|
});
|
|
169
186
|
this.credentials = options.credentials ?? null;
|
|
187
|
+
this.audit = options.audit ?? null;
|
|
170
188
|
const store = this.credentials;
|
|
171
189
|
this.links = new BridgeLinks(
|
|
172
190
|
this.resolveNode,
|
|
@@ -354,13 +372,76 @@ export class MeshService {
|
|
|
354
372
|
/**
|
|
355
373
|
* Push one command to a device and await its result (or time out). The
|
|
356
374
|
* low-level primitive under every command tool; exposed for tests and
|
|
357
|
-
* future tools.
|
|
375
|
+
* future tools. Every dispatch lands in the command audit.
|
|
358
376
|
*/
|
|
359
377
|
sendCommand(
|
|
360
378
|
device: DeviceInfo,
|
|
361
379
|
name: string,
|
|
362
380
|
params: Record<string, unknown> = {},
|
|
363
381
|
timeoutMs = this.commandTimeoutMs,
|
|
382
|
+
): Promise<DeviceCommandResult> {
|
|
383
|
+
const finishAudit = this.beginAudit(device, name, params);
|
|
384
|
+
const pending = this.deliverCommand(device, name, params, timeoutMs);
|
|
385
|
+
if (!finishAudit) return pending;
|
|
386
|
+
// The delivery promise only ever resolves, so this adds no rejection.
|
|
387
|
+
return pending.then((result) => {
|
|
388
|
+
finishAudit(result);
|
|
389
|
+
return result;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Open the audit record for one dispatch. The issuer is read now, while
|
|
395
|
+
* the issuing turn's scope is live. Returns the completion hook, or null
|
|
396
|
+
* when nothing is audited. Never throws: a broken audit must not stop a
|
|
397
|
+
* command.
|
|
398
|
+
*/
|
|
399
|
+
private beginAudit(
|
|
400
|
+
device: DeviceInfo,
|
|
401
|
+
name: string,
|
|
402
|
+
params: Record<string, unknown>,
|
|
403
|
+
): ((result: DeviceCommandResult) => void) | null {
|
|
404
|
+
const audit = this.audit;
|
|
405
|
+
if (!audit) return null;
|
|
406
|
+
try {
|
|
407
|
+
const startedAt = Date.now();
|
|
408
|
+
const base = {
|
|
409
|
+
time: new Date(startedAt).toISOString(),
|
|
410
|
+
issuer: auditIssuer(),
|
|
411
|
+
deviceId: device.id,
|
|
412
|
+
deviceName: device.name,
|
|
413
|
+
command: name,
|
|
414
|
+
argsHash: hashCommandArgs(params),
|
|
415
|
+
};
|
|
416
|
+
return (result) => {
|
|
417
|
+
try {
|
|
418
|
+
audit.record({
|
|
419
|
+
...base,
|
|
420
|
+
ok: result.ok,
|
|
421
|
+
...(result.ok ? {} : { error: auditErrorText(result.message) }),
|
|
422
|
+
durationMs: Date.now() - startedAt,
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
logWarn("mesh", `mesh.audit event=record_failed err=${String(err)}`);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
} catch (err) {
|
|
429
|
+
logWarn("mesh", `mesh.audit event=record_failed err=${String(err)}`);
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** `talon mesh audit`: the newest recorded dispatches, oldest first. */
|
|
435
|
+
readAudit(query: MeshAuditQuery = {}): Promise<MeshAuditEntry[]> {
|
|
436
|
+
return this.audit ? this.audit.read(query) : Promise.resolve([]);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Deliver one command; resolves with its result, a timeout, or a loss. */
|
|
440
|
+
private deliverCommand(
|
|
441
|
+
device: DeviceInfo,
|
|
442
|
+
name: string,
|
|
443
|
+
params: Record<string, unknown>,
|
|
444
|
+
timeoutMs: number,
|
|
364
445
|
): Promise<DeviceCommandResult> {
|
|
365
446
|
const command: DeviceCommand = {
|
|
366
447
|
id: randomUUID(),
|
|
@@ -732,8 +813,14 @@ export class MeshService {
|
|
|
732
813
|
query: unknown,
|
|
733
814
|
localApkPath: unknown,
|
|
734
815
|
remotePath?: unknown,
|
|
816
|
+
allowDowngrade?: unknown,
|
|
735
817
|
): Promise<MeshToolResult> {
|
|
736
|
-
return this.files.updateDeviceApp(
|
|
818
|
+
return this.files.updateDeviceApp(
|
|
819
|
+
query,
|
|
820
|
+
localApkPath,
|
|
821
|
+
remotePath,
|
|
822
|
+
allowDowngrade,
|
|
823
|
+
);
|
|
737
824
|
}
|
|
738
825
|
|
|
739
826
|
/** `update_node`: remote self-update for a headless talon-node. */
|
|
@@ -741,8 +828,14 @@ export class MeshService {
|
|
|
741
828
|
query: unknown,
|
|
742
829
|
localBinaryPath?: unknown,
|
|
743
830
|
remotePath?: unknown,
|
|
831
|
+
allowDowngrade?: unknown,
|
|
744
832
|
): Promise<MeshToolResult> {
|
|
745
|
-
return this.files.updateNodeBinary(
|
|
833
|
+
return this.files.updateNodeBinary(
|
|
834
|
+
query,
|
|
835
|
+
localBinaryPath,
|
|
836
|
+
remotePath,
|
|
837
|
+
allowDowngrade,
|
|
838
|
+
);
|
|
746
839
|
}
|
|
747
840
|
|
|
748
841
|
// ── Provisioning + pairing (see links/bridge-links.ts) ────────────────────
|
|
@@ -784,8 +877,12 @@ export class MeshService {
|
|
|
784
877
|
}
|
|
785
878
|
|
|
786
879
|
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
787
|
-
openNodeInstall(
|
|
788
|
-
|
|
880
|
+
openNodeInstall(
|
|
881
|
+
token: string,
|
|
882
|
+
os?: string | null,
|
|
883
|
+
arch?: string | null,
|
|
884
|
+
): Promise<{ script: string; filename: string } | null> {
|
|
885
|
+
return this.links.openNodeInstall(token, os, arch);
|
|
789
886
|
}
|
|
790
887
|
|
|
791
888
|
/** GET /node/binary — serve a grant's binary (single-use). */
|
|
@@ -1170,6 +1267,7 @@ let instance: MeshService | null = null;
|
|
|
1170
1267
|
export function getMeshService(): MeshService {
|
|
1171
1268
|
instance ??= new MeshService(undefined, {
|
|
1172
1269
|
credentials: new DeviceCredentialStore(),
|
|
1270
|
+
audit: new MeshAuditLog(),
|
|
1173
1271
|
});
|
|
1174
1272
|
return instance;
|
|
1175
1273
|
}
|
|
@@ -23,7 +23,13 @@ import {
|
|
|
23
23
|
normalizeGoos,
|
|
24
24
|
type NodeBinaryResolver,
|
|
25
25
|
} from "./node-binaries.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
autoInstallOneLiners,
|
|
28
|
+
checkBridgeUrl,
|
|
29
|
+
installOneLiner,
|
|
30
|
+
installRefusalScript,
|
|
31
|
+
NodeProvisionStore,
|
|
32
|
+
} from "./node-provision.js";
|
|
27
33
|
import {
|
|
28
34
|
DEFAULT_COMPANION_SCOPES,
|
|
29
35
|
NODE_SCOPES,
|
|
@@ -56,6 +62,8 @@ export type MeshBridgeInfo = {
|
|
|
56
62
|
token?: string;
|
|
57
63
|
/** TLS certificate SHA-256 (absent over plain HTTP). */
|
|
58
64
|
fingerprint?: string;
|
|
65
|
+
/** Base64 SHA-256 of the TLS key's SPKI, curl's pin (absent over HTTP). */
|
|
66
|
+
spkiPin?: string;
|
|
59
67
|
/**
|
|
60
68
|
* The operator's `native.publicUrl`: what devices dial when the bind
|
|
61
69
|
* address isn't reachable as-is (containers, NAT, proxies).
|
|
@@ -135,9 +143,10 @@ export class BridgeLinks {
|
|
|
135
143
|
name?: unknown,
|
|
136
144
|
bridgeUrl?: unknown,
|
|
137
145
|
): Promise<MeshToolResult> {
|
|
146
|
+
const auto = isBlank(os) && isBlank(arch);
|
|
138
147
|
const goos = normalizeGoos(os);
|
|
139
148
|
const goarch = normalizeGoarch(arch);
|
|
140
|
-
if (!goos || !goarch) {
|
|
149
|
+
if (!auto && (!goos || !goarch)) {
|
|
141
150
|
return { ok: false, text: unknownTargetText(os, arch) };
|
|
142
151
|
}
|
|
143
152
|
const info = this.bridgeInfo;
|
|
@@ -155,6 +164,33 @@ export class BridgeLinks {
|
|
|
155
164
|
}
|
|
156
165
|
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
157
166
|
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
167
|
+
if (auto || !goos || !goarch) {
|
|
168
|
+
const pending = this.provision.createAuto({
|
|
169
|
+
...(typeof name === "string" && name.trim()
|
|
170
|
+
? { name: name.trim() }
|
|
171
|
+
: {}),
|
|
172
|
+
bridgeUrl: base,
|
|
173
|
+
bearerToken: this.linkCredential(info.token, NODE_SCOPES, "install"),
|
|
174
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
175
|
+
...(info.spkiPin ? { spkiPin: info.spkiPin } : {}),
|
|
176
|
+
});
|
|
177
|
+
const cmd = autoInstallOneLiners(pending);
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
text: [
|
|
181
|
+
"Run ONE of these on the new host — it reports its own OS and CPU, and the bridge picks the matching talon-node:",
|
|
182
|
+
"",
|
|
183
|
+
"Linux / macOS:",
|
|
184
|
+
` ${cmd.posix}`,
|
|
185
|
+
"",
|
|
186
|
+
"Windows (elevated PowerShell or cmd):",
|
|
187
|
+
` ${cmd.windows}`,
|
|
188
|
+
"",
|
|
189
|
+
`It installs talon-node (sha256-verified), pins the bridge certificate, and registers a boot service — the host appears on the mesh within a minute. Supported: ${NODE_TARGETS.map((t) => `${t.goos}/${t.goarch}`).join(", ")}.`,
|
|
190
|
+
`Single-use link, expires in 30 minutes. The host must be able to reach ${base}.`,
|
|
191
|
+
].join("\n"),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
158
194
|
let bin;
|
|
159
195
|
try {
|
|
160
196
|
bin = await this.resolveNode(goos, goarch);
|
|
@@ -172,6 +208,7 @@ export class BridgeLinks {
|
|
|
172
208
|
bridgeUrl: base,
|
|
173
209
|
bearerToken: this.linkCredential(info.token, NODE_SCOPES, "install"),
|
|
174
210
|
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
211
|
+
...(info.spkiPin ? { spkiPin: info.spkiPin } : {}),
|
|
175
212
|
});
|
|
176
213
|
return {
|
|
177
214
|
ok: true,
|
|
@@ -298,7 +335,46 @@ export class BridgeLinks {
|
|
|
298
335
|
}
|
|
299
336
|
|
|
300
337
|
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
301
|
-
|
|
338
|
+
/**
|
|
339
|
+
* An auto grant is pinned here first, from the os/arch its one-liner
|
|
340
|
+
* reported; a host with no build gets a failing script and the grant
|
|
341
|
+
* stays unspent.
|
|
342
|
+
*/
|
|
343
|
+
async openNodeInstall(
|
|
344
|
+
token: string,
|
|
345
|
+
os?: string | null,
|
|
346
|
+
arch?: string | null,
|
|
347
|
+
): Promise<{ script: string; filename: string } | null> {
|
|
348
|
+
if (this.provision.isPending(token)) {
|
|
349
|
+
const goos = normalizeGoos(os);
|
|
350
|
+
const goarch = normalizeGoarch(arch);
|
|
351
|
+
if (!goos || !goarch) {
|
|
352
|
+
return {
|
|
353
|
+
script: installRefusalScript(
|
|
354
|
+
`no talon-node build for os=${os ?? ""} arch=${arch ?? ""}`,
|
|
355
|
+
),
|
|
356
|
+
filename: "install-talon-node.txt",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const bin = await this.resolveNode(goos, goarch);
|
|
361
|
+
this.provision.pin(token, {
|
|
362
|
+
goos,
|
|
363
|
+
goarch,
|
|
364
|
+
binaryPath: bin.path,
|
|
365
|
+
sha256: bin.sha256,
|
|
366
|
+
size: bin.size,
|
|
367
|
+
version: bin.version,
|
|
368
|
+
});
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return {
|
|
371
|
+
script: installRefusalScript(
|
|
372
|
+
`could not resolve talon-node for ${goos}/${goarch}: ${(err as Error).message}`,
|
|
373
|
+
),
|
|
374
|
+
filename: "install-talon-node.txt",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
302
378
|
return this.provision.openScript(token);
|
|
303
379
|
}
|
|
304
380
|
|
|
@@ -318,17 +394,17 @@ export class BridgeLinks {
|
|
|
318
394
|
info: MeshBridgeInfo,
|
|
319
395
|
explicit?: unknown,
|
|
320
396
|
): string | { error: string } {
|
|
397
|
+
// Both URLs end up inside generated installer scripts, so they are held
|
|
398
|
+
// to a quote-safe alphabet (checkBridgeUrl), not just "looks like a URL".
|
|
321
399
|
if (typeof explicit === "string" && explicit.trim()) {
|
|
322
|
-
|
|
323
|
-
if (!/^https?:\/\/\S+$/.test(url)) {
|
|
324
|
-
return { error: `bridge_url must be an http(s) URL, got "${url}".` };
|
|
325
|
-
}
|
|
326
|
-
return url;
|
|
400
|
+
return checkBridgeUrl(explicit);
|
|
327
401
|
}
|
|
328
402
|
// Inside a container the "first external IPv4" is the container's own
|
|
329
403
|
// bridge-network address, which no phone can reach — the operator's
|
|
330
404
|
// public URL is the only right answer there.
|
|
331
|
-
if (info.publicUrl)
|
|
405
|
+
if (info.publicUrl) {
|
|
406
|
+
return checkBridgeUrl(info.publicUrl, "native.publicUrl");
|
|
407
|
+
}
|
|
332
408
|
let host = info.host;
|
|
333
409
|
if (host === "0.0.0.0" || host === "::") {
|
|
334
410
|
const external = firstExternalIPv4();
|
|
@@ -348,6 +424,15 @@ export class BridgeLinks {
|
|
|
348
424
|
}
|
|
349
425
|
}
|
|
350
426
|
|
|
427
|
+
/** An omitted tool argument: absent, or an empty/whitespace string. */
|
|
428
|
+
function isBlank(value: unknown): boolean {
|
|
429
|
+
return (
|
|
430
|
+
value === undefined ||
|
|
431
|
+
value === null ||
|
|
432
|
+
(typeof value === "string" && !value.trim())
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
351
436
|
/** Error text for an os/arch pair outside the talon-node build matrix. */
|
|
352
437
|
function unknownTargetText(os: unknown, arch: unknown): string {
|
|
353
438
|
return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
|