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.
@@ -253,7 +253,11 @@ export class DeviceCredentialStore {
253
253
  });
254
254
  }
255
255
  const records = this.activeRecordsFor(deviceId);
256
- for (const record of records) record.scopes = next;
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 (least privilege — see docs/mesh-credentials.md):
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
- /** What a paired or upgraded companion gets unless the operator widens it. */
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(query, localApkPath, remotePath);
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(query, localBinaryPath, remotePath);
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(token: string): { script: string; filename: string } | null {
788
- return this.links.openNodeInstall(token);
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 { installOneLiner, NodeProvisionStore } from "./node-provision.js";
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
- openNodeInstall(token: string): { script: string; filename: string } | null {
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
- const url = explicit.trim().replace(/\/+$/, "");
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) return info.publicUrl.trim().replace(/\/+$/, "");
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(