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.
@@ -12,15 +12,21 @@
12
12
  * daemon → device (command): upload_file { token, path }
13
13
  * device → daemon (HTTP): POST /devices/file?transfer=token (raw body)
14
14
  * bridge route: acceptUpload(token, stream) → tmp+rename
15
- * device → daemon (command result): ok + bytes — the command round trip
16
- * doubles as the completion signal.
15
+ * device → daemon (command result): ok + bytes + sha256 — the command
16
+ * round trip doubles as the completion signal, and the daemon checks the
17
+ * device's digest against the one it computed while receiving.
17
18
  *
18
19
  * push (daemon → device):
19
20
  * daemon: token = createPush(deviceId, sourcePath)
20
- * daemon → device (command): download_file { token, path }
21
+ * daemon → device (command): download_file { token, path, sha256 }
21
22
  * device → daemon (HTTP): GET /devices/file?transfer=token
22
23
  * bridge route: openDownload(token) → stream the source
23
- * device writes to its path, answers the command with ok + bytes.
24
+ * device hashes while writing its temp file, refuses to rename it into
25
+ * place on a digest mismatch, and answers with ok + bytes + sha256.
26
+ *
27
+ * The digests are additive: a device build that predates them ignores the
28
+ * push param and omits the pull result key, and the daemon then skips the
29
+ * check.
24
30
  *
25
31
  * Tokens are single-use, bound to one device + one path, and expire unused.
26
32
  * The registry never trusts the HTTP caller with a path — the token IS the
@@ -30,7 +36,7 @@
30
36
  * itself must name the device the token was minted for (see take()).
31
37
  */
32
38
 
33
- import { randomBytes } from "node:crypto";
39
+ import { createHash, randomBytes } from "node:crypto";
34
40
  import { createWriteStream } from "node:fs";
35
41
  import { mkdir, rename, rm, stat } from "node:fs/promises";
36
42
  import { dirname } from "node:path";
@@ -40,6 +46,10 @@ import type { Readable } from "node:stream";
40
46
  /** Unused tokens die after this long (transfer not started). */
41
47
  const TOKEN_TTL_MS = 10 * 60 * 1000;
42
48
 
49
+ /** What a finished pull delivered: its size and the SHA-256 (hex) of the
50
+ * bytes as they arrived, hashed in stream. */
51
+ export type PullReceipt = { bytes: number; sha256: string };
52
+
43
53
  type Transfer = {
44
54
  token: string;
45
55
  direction: "pull" | "push";
@@ -49,10 +59,10 @@ type Transfer = {
49
59
  createdAt: number;
50
60
  /** Set once the HTTP leg has started (single-use latch). */
51
61
  consumed: boolean;
52
- /** pull only — resolved by acceptUpload with the byte count. */
62
+ /** pull only — resolved by acceptUpload with what arrived. */
53
63
  uploadDone?: {
54
- promise: Promise<number>;
55
- resolve: (bytes: number) => void;
64
+ promise: Promise<PullReceipt>;
65
+ resolve: (receipt: PullReceipt) => void;
56
66
  reject: (err: Error) => void;
57
67
  };
58
68
  /** pull only — tears down the upload once its HTTP leg is streaming. */
@@ -63,16 +73,17 @@ export class TransferStore {
63
73
  private readonly transfers = new Map<string, Transfer>();
64
74
 
65
75
  /** Arrange a device→daemon transfer. Returns the token to send to the
66
- * device and a promise that resolves (bytes) when the upload lands. */
76
+ * device and a promise that resolves (bytes + digest) when the upload
77
+ * lands. */
67
78
  createPull(
68
79
  deviceId: string,
69
80
  destPath: string,
70
- ): { token: string; done: Promise<number> } {
81
+ ): { token: string; done: Promise<PullReceipt> } {
71
82
  this.sweep();
72
83
  const token = randomBytes(24).toString("base64url");
73
- let resolve!: (bytes: number) => void;
84
+ let resolve!: (receipt: PullReceipt) => void;
74
85
  let reject!: (err: Error) => void;
75
- const promise = new Promise<number>((res, rej) => {
86
+ const promise = new Promise<PullReceipt>((res, rej) => {
76
87
  resolve = res;
77
88
  reject = rej;
78
89
  });
@@ -125,7 +136,8 @@ export class TransferStore {
125
136
  /**
126
137
  * Bridge route: a device is streaming a pull's file body up. Writes to a
127
138
  * temp file and renames into place, so a dropped connection can't leave a
128
- * half-written destination. Resolves the pull's `done` promise.
139
+ * half-written destination. Resolves the pull's `done` promise with the
140
+ * SHA-256 of the bytes, hashed as they stream through (no second read).
129
141
  *
130
142
  * `fromDeviceId` is the device the HTTP caller says it is (see take()).
131
143
  */
@@ -143,14 +155,18 @@ export class TransferStore {
143
155
  try {
144
156
  await mkdir(dirname(t.localPath), { recursive: true });
145
157
  let bytes = 0;
146
- body.on("data", (d: Buffer) => (bytes += d.length));
158
+ const hash = createHash("sha256");
159
+ body.on("data", (d: Buffer) => {
160
+ bytes += d.length;
161
+ hash.update(d);
162
+ });
147
163
  await pipeline(body, createWriteStream(tmp, { mode: 0o600 }), {
148
164
  signal: abort.signal,
149
165
  });
150
166
  abort.signal.throwIfAborted();
151
167
  await rename(tmp, t.localPath);
152
168
  this.transfers.delete(token);
153
- t.uploadDone?.resolve(bytes);
169
+ t.uploadDone?.resolve({ bytes, sha256: hash.digest("hex") });
154
170
  return { ok: true, bytes };
155
171
  } catch (err) {
156
172
  await rm(tmp, { force: true }).catch(() => {});
@@ -181,6 +181,12 @@ export const meshTools: ToolDefinition[] = [
181
181
  .describe(
182
182
  "Where to stage the APK on the device (default /sdcard/Download/talon-companion-update.apk).",
183
183
  ),
184
+ allow_downgrade: z
185
+ .boolean()
186
+ .optional()
187
+ .describe(
188
+ "Install even when the APK has a lower versionCode (pm install -d), for a deliberate rollback.",
189
+ ),
184
190
  },
185
191
  execute: (params, bridge) => bridge("update_device", params),
186
192
  tag: "mesh",
@@ -203,6 +209,12 @@ export const meshTools: ToolDefinition[] = [
203
209
  .describe(
204
210
  "Where to stage the binary on the node (default /tmp/talon-node.update; the node re-stages next to its own executable before the atomic swap).",
205
211
  ),
212
+ allow_downgrade: z
213
+ .boolean()
214
+ .optional()
215
+ .describe(
216
+ "Install even when the binary is an older version, or the exact build already running. The node refuses both without it.",
217
+ ),
206
218
  },
207
219
  execute: (params, bridge) => bridge("update_node", params),
208
220
  tag: "mesh",
@@ -223,12 +235,20 @@ export const meshTools: ToolDefinition[] = [
223
235
  {
224
236
  name: "make_node_install_link",
225
237
  description:
226
- "Mint a single-use install link served by this daemon's bridge and return the one command that attaches a fresh Linux/macOS/Windows host to the mesh as a headless talon-node. Running it on the host downloads the installer script and binary from the bridge (sha256-verified), installs talon-node, pre-pins the bridge TLS certificate, embeds the bearer token, and registers a boot service — no toolchain, package manager, or manual config on the host. The link expires in 30 minutes and each leg serves exactly once; the host only needs to reach the bridge URL. Requires the native bridge running on a non-loopback bind with a token.",
238
+ "Mint a single-use install link served by this daemon's bridge and return the one command that attaches a fresh Linux/macOS/Windows host to the mesh as a headless talon-node. Running it on the host downloads the installer script and binary from the bridge (sha256-verified), installs talon-node, pre-pins the bridge TLS certificate, embeds the bearer token, and registers a boot service — no toolchain, package manager, or manual config on the host. Omit os and arch when you don't know the host: you get a POSIX and a PowerShell command, and whichever runs reports its own platform so the bridge picks the right binary. The link expires in 30 minutes and each leg serves exactly once; the host only needs to reach the bridge URL. Requires the native bridge running on a non-loopback bind with a token.",
227
239
  schema: {
228
- os: z.string().describe("Host OS: linux, macos/darwin, or windows."),
240
+ os: z
241
+ .string()
242
+ .optional()
243
+ .describe(
244
+ "Host OS: linux, macos/darwin, or windows. Omit (with arch) to auto-detect on the host.",
245
+ ),
229
246
  arch: z
230
247
  .string()
231
- .describe("Host arch: amd64/x86_64, arm64/aarch64, or arm."),
248
+ .optional()
249
+ .describe(
250
+ "Host arch: amd64/x86_64, arm64/aarch64, or arm. Omit (with os) to auto-detect on the host.",
251
+ ),
232
252
  name: z
233
253
  .string()
234
254
  .optional()
@@ -239,7 +259,7 @@ export const meshTools: ToolDefinition[] = [
239
259
  .string()
240
260
  .optional()
241
261
  .describe(
242
- "Bridge base URL as reachable FROM the new host (e.g. https://100.64.0.7:19880). Default: derived from the bridge bind (wildcard binds use this host's first external IPv4).",
262
+ "Bridge base URL as reachable FROM the new host (e.g. https://100.64.0.7:19880). Default: native.publicUrl when set, else derived from the bridge bind (wildcard binds use this host's first external IPv4).",
243
263
  ),
244
264
  },
245
265
  execute: (params, bridge) => bridge("make_node_install_link", params),
@@ -99,7 +99,10 @@ export class Weaver {
99
99
  const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
100
100
  // The turn id is minted at enqueue so a queued turn's wait is already
101
101
  // attributable; the log scope goes live when the turn starts running.
102
- const scope = createTurnScope(params.chatId);
102
+ const scope = createTurnScope(params.chatId, {
103
+ sender: params.senderKeys?.[0] ?? (params.senderName || undefined),
104
+ source: params.source,
105
+ });
103
106
  const trace = createTurnTrace(scope.turnId, params, thread.inFlightCount);
104
107
  // Registered before enqueueing so a turn waiting in its chat's FIFO is
105
108
  // visible as `queued` in the task table, not invisible until it runs.
@@ -6,8 +6,9 @@
6
6
  * shared token → a per-device credential bound to the device id in the
7
7
  * body (the migration path off `native.token`). Scopes are what the
8
8
  * client asks for, capped by policy: a node gets `device`; a companion
9
- * at most `native.companionScopes` (default device + client). Operator
10
- * is never granted in-band unless the operator put it in that list.
9
+ * at most `native.companionScopes` (default: all three, as the shared
10
+ * token it is trading carried). A companion that asks for `client`
11
+ * also gets `operator` whenever that list allows it.
11
12
  *
12
13
  * per-device credential → a replacement with the same device and scopes
13
14
  * (rotation, when the operator asked for it or the client wants one).
@@ -48,7 +49,19 @@ function grantFor(
48
49
  const ceiling: readonly BridgeScope[] =
49
50
  client === "node" ? ["device"] : credentials.policy.companionScopes;
50
51
  if (asked.length === 0) return [...ceiling];
51
- return asked.filter((s) => ceiling.includes(s));
52
+ const granted = asked.filter((s) => ceiling.includes(s));
53
+ // Shipped companions ask for device + client: the list predates operator
54
+ // being part of the default. The companion is the full UI (settings,
55
+ // extensions), so it gets operator whenever the policy allows it.
56
+ if (
57
+ client !== "node" &&
58
+ granted.includes("client") &&
59
+ ceiling.includes("operator") &&
60
+ !granted.includes("operator")
61
+ ) {
62
+ granted.push("operator");
63
+ }
64
+ return granted;
52
65
  }
53
66
 
54
67
  export async function upgradeCredential(
@@ -149,8 +149,18 @@ export type BridgeServerHandlers = {
149
149
  token: string,
150
150
  format: "html" | "json",
151
151
  ): { contentType: string; body: string } | null;
152
- /** Resolve a node-provisioning token to its installer script, or null. */
153
- openNodeInstall(token: string): { script: string; filename: string } | null;
152
+ /**
153
+ * Resolve a node-provisioning token to its installer script, or null. An
154
+ * auto link passes the os/arch its host reported.
155
+ */
156
+ openNodeInstall(
157
+ token: string,
158
+ os?: string | null,
159
+ arch?: string | null,
160
+ ):
161
+ | { script: string; filename: string }
162
+ | null
163
+ | Promise<{ script: string; filename: string } | null>;
154
164
  /** Resolve a node-provisioning token to the binary to stream, or null. */
155
165
  openNodeBinary(token: string): { path: string; size: number } | null;
156
166
  };
@@ -73,9 +73,15 @@ export function preAuthRoutes(
73
73
  // the single-use grant token (minted by make_node_install_link,
74
74
  // expiring, one serve per leg) is the entire authorization, the same
75
75
  // trust model as streamed-transfer tokens.
76
- "GET /node/install": ({ res, url }) => {
76
+ "GET /node/install": async ({ res, url }) => {
77
77
  const token = url.searchParams.get("provision") ?? "";
78
- const install = token ? h.openNodeInstall(token) : null;
78
+ const install = token
79
+ ? await h.openNodeInstall(
80
+ token,
81
+ url.searchParams.get("os"),
82
+ url.searchParams.get("arch"),
83
+ )
84
+ : null;
79
85
  if (!install) return host.unknownProvision(res);
80
86
  res.writeHead(200, {
81
87
  "Content-Type": "text/plain; charset=utf-8",
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../../core/frontend-runtime/alerts.js";
32
32
  import { errorText } from "../../health/outage.js";
33
33
  import {
34
+ certificateSpkiPin,
34
35
  formatFingerprint,
35
36
  isLoopbackHost,
36
37
  type BridgeTlsIdentity,
@@ -196,6 +197,13 @@ export class BridgeServer {
196
197
  return this.tlsIdentity?.fingerprint ?? null;
197
198
  }
198
199
 
200
+ /** The served key's SPKI pin (base64 SHA-256), or null over HTTP. */
201
+ getSpkiPin(): string | null {
202
+ return this.tlsIdentity
203
+ ? certificateSpkiPin(this.tlsIdentity.certPem)
204
+ : null;
205
+ }
206
+
199
207
  /**
200
208
  * Push an event to every connected SSE client that may see it. Chat
201
209
  * traffic is for `client`-scoped sessions only; a device-only credential
@@ -228,6 +228,21 @@ export function certificateFingerprint(certPem: string): string {
228
228
  .digest("hex");
229
229
  }
230
230
 
231
+ /**
232
+ * The certificate's public-key pin: base64 SHA-256 of its DER
233
+ * SubjectPublicKeyInfo — the value curl's `--pinnedpubkey sha256//…` checks.
234
+ */
235
+ export function certificateSpkiPin(certPem: string): string {
236
+ return createHash("sha256")
237
+ .update(
238
+ new X509Certificate(certPem).publicKey.export({
239
+ type: "spki",
240
+ format: "der",
241
+ }),
242
+ )
243
+ .digest("base64");
244
+ }
245
+
231
246
  /** AA:BB:… presentation of a fingerprint, for logs and pairing screens. */
232
247
  export function formatFingerprint(fingerprint: string): string {
233
248
  return (fingerprint.match(/.{2}/g) ?? []).join(":").toUpperCase();
@@ -33,6 +33,7 @@ import { createNativeRuntime, type NativeRuntime } from "./runtime.js";
33
33
  import { BridgeServer, type BridgeCredentials } from "./bridge/server.js";
34
34
  import {
35
35
  DEFAULT_COMPANION_SCOPES,
36
+ FORMER_COMPANION_SCOPES,
36
37
  type MeshScope,
37
38
  } from "../../core/mesh/credentials/index.js";
38
39
  import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./bridge/tls.js";
@@ -106,6 +107,28 @@ function bridgeCredentials(
106
107
  };
107
108
  }
108
109
 
110
+ /**
111
+ * Companions paired while the default was device + client get the current
112
+ * default too — unless the operator set `native.companionScopes` (their
113
+ * choice then stands) or set that device's scopes by hand.
114
+ */
115
+ async function adoptCompanionDefault(
116
+ config: TalonConfig,
117
+ mesh: NativeRuntime["mesh"],
118
+ ): Promise<void> {
119
+ if (config.native?.companionScopes || !mesh.credentials) return;
120
+ const moved = await mesh.credentials.adoptDefaultScopes(
121
+ FORMER_COMPANION_SCOPES,
122
+ DEFAULT_COMPANION_SCOPES,
123
+ );
124
+ if (moved > 0) {
125
+ log(
126
+ "native",
127
+ `Granted the default companion scopes (${DEFAULT_COMPANION_SCOPES.join(", ")}) to ${moved} credential(s) issued under the old device + client default; set native.companionScopes to narrow them.`,
128
+ );
129
+ }
130
+ }
131
+
109
132
  /**
110
133
  * Plug this bridge in as the mesh's transport: locates and device commands
111
134
  * (from ANY frontend's mesh tool calls) leave as SSE events.
@@ -189,6 +212,7 @@ export function createNativeFrontend(
189
212
 
190
213
  async init() {
191
214
  await mesh.load();
215
+ await adoptCompanionDefault(config, mesh);
192
216
  unregisterMeshTransport = registerMeshTransport(runtime, server);
193
217
  // Mesh tool actions (list_devices / get_device_location) are shared
194
218
  // gateway actions — no native-only cases here.
@@ -226,6 +250,7 @@ export function createNativeFrontend(
226
250
  );
227
251
  }
228
252
  const fingerprint = server.getFingerprint();
253
+ const spkiPin = server.getSpkiPin();
229
254
  // Tell the mesh how this bridge is reachable — everything a generated
230
255
  // node installer needs (make_node_install_link fails cleanly without it).
231
256
  mesh.setBridgeInfo({
@@ -234,6 +259,7 @@ export function createNativeFrontend(
234
259
  port: server.getPort(),
235
260
  ...(listen.token ? { token: listen.token } : {}),
236
261
  ...(fingerprint ? { fingerprint } : {}),
262
+ ...(spkiPin ? { spkiPin } : {}),
237
263
  ...(config.native?.publicUrl
238
264
  ? { publicUrl: config.native.publicUrl }
239
265
  : {}),
@@ -191,7 +191,7 @@ export function buildBridgeHandlers(
191
191
  openFileDownload: (token, fromDeviceId) =>
192
192
  mesh.openFileDownload(token, fromDeviceId),
193
193
  openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
194
- openNodeInstall: (token) => mesh.openNodeInstall(token),
194
+ openNodeInstall: (token, os, arch) => mesh.openNodeInstall(token, os, arch),
195
195
  openNodeBinary: (token) => mesh.openNodeBinary(token),
196
196
  };
197
197
  }
@@ -24,9 +24,18 @@
24
24
  import { AsyncLocalStorage } from "node:async_hooks";
25
25
  import { randomBytes } from "node:crypto";
26
26
 
27
+ /** Who a turn acts for — recorded by audits of what the turn did. */
28
+ export type TurnIssuer = {
29
+ /** The sender's operator key, else display name; absent when unknown. */
30
+ readonly sender?: string;
31
+ /** What started the turn: message, cron, trigger, pulse, agent. */
32
+ readonly source?: string;
33
+ };
34
+
27
35
  export type TurnLogScope = {
28
36
  readonly turnId: string;
29
37
  readonly chatId: string;
38
+ readonly issuer: TurnIssuer;
30
39
  /** False once the turn has settled — the scope then tags nothing. */
31
40
  open: boolean;
32
41
  };
@@ -41,8 +50,11 @@ function mintTurnId(): string {
41
50
  }
42
51
 
43
52
  /** A fresh scope for a turn that has been accepted but not yet started. */
44
- export function createTurnScope(chatId: string): TurnLogScope {
45
- return { turnId: mintTurnId(), chatId, open: true };
53
+ export function createTurnScope(
54
+ chatId: string,
55
+ issuer: TurnIssuer = {},
56
+ ): TurnLogScope {
57
+ return { turnId: mintTurnId(), chatId, issuer, open: true };
46
58
  }
47
59
 
48
60
  /**
@@ -83,3 +95,12 @@ export function currentTurnId(): string | undefined {
83
95
  const scope = storage.getStore();
84
96
  return scope?.open ? scope.turnId : undefined;
85
97
  }
98
+
99
+ /** The live turn for the current async chain — who is acting — if any. */
100
+ export function currentTurn():
101
+ { turnId: string; chatId: string; issuer: TurnIssuer } | undefined {
102
+ const scope = storage.getStore();
103
+ return scope?.open
104
+ ? { turnId: scope.turnId, chatId: scope.chatId, issuer: scope.issuer }
105
+ : undefined;
106
+ }