talon-agent 5.21.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.21.0",
3
+ "version": "5.22.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -101,7 +101,7 @@
101
101
  "build:fusefs": "node native/talon-fusefs/build.mjs"
102
102
  },
103
103
  "dependencies": {
104
- "@anthropic-ai/claude-agent-sdk": "^0.3.283",
104
+ "@anthropic-ai/claude-agent-sdk": "^0.3.284",
105
105
  "@anthropic-ai/sdk": "^0.104.1",
106
106
  "@brave/brave-search-mcp-server": "^2.0.75",
107
107
  "@clack/prompts": "^1.2.0",
@@ -877,8 +877,12 @@ export class MeshService {
877
877
  }
878
878
 
879
879
  /** GET /node/install — serve a grant's installer script (single-use). */
880
- openNodeInstall(token: string): { script: string; filename: string } | null {
881
- 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);
882
886
  }
883
887
 
884
888
  /** GET /node/binary — serve a grant's binary (single-use). */
@@ -24,8 +24,10 @@ import {
24
24
  type NodeBinaryResolver,
25
25
  } from "./node-binaries.js";
26
26
  import {
27
+ autoInstallOneLiners,
27
28
  checkBridgeUrl,
28
29
  installOneLiner,
30
+ installRefusalScript,
29
31
  NodeProvisionStore,
30
32
  } from "./node-provision.js";
31
33
  import {
@@ -141,9 +143,10 @@ export class BridgeLinks {
141
143
  name?: unknown,
142
144
  bridgeUrl?: unknown,
143
145
  ): Promise<MeshToolResult> {
146
+ const auto = isBlank(os) && isBlank(arch);
144
147
  const goos = normalizeGoos(os);
145
148
  const goarch = normalizeGoarch(arch);
146
- if (!goos || !goarch) {
149
+ if (!auto && (!goos || !goarch)) {
147
150
  return { ok: false, text: unknownTargetText(os, arch) };
148
151
  }
149
152
  const info = this.bridgeInfo;
@@ -161,6 +164,33 @@ export class BridgeLinks {
161
164
  }
162
165
  const base = this.bridgeBaseUrl(info, bridgeUrl);
163
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
+ }
164
194
  let bin;
165
195
  try {
166
196
  bin = await this.resolveNode(goos, goarch);
@@ -305,7 +335,46 @@ export class BridgeLinks {
305
335
  }
306
336
 
307
337
  /** GET /node/install — serve a grant's installer script (single-use). */
308
- 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
+ }
309
378
  return this.provision.openScript(token);
310
379
  }
311
380
 
@@ -355,6 +424,15 @@ export class BridgeLinks {
355
424
  }
356
425
  }
357
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
+
358
436
  /** Error text for an os/arch pair outside the talon-node build matrix. */
359
437
  function unknownTargetText(os: unknown, arch: unknown): string {
360
438
  return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
@@ -9,6 +9,12 @@
9
9
  * GET /node/install?provision=<token> → installer script (once)
10
10
  * GET /node/binary?provision=<token> → the binary itself (once)
11
11
  *
12
+ * A grant can also be minted WITHOUT a target ("auto"): the one-liner then
13
+ * reports the host's own `uname -s`/`uname -m` (or PowerShell's
14
+ * PROCESSOR_ARCHITECTURE) on the script fetch, and the caller resolves the
15
+ * binary and pins it into the grant before the script is rendered — so the
16
+ * script still embeds the digest and nothing about integrity changes.
17
+ *
12
18
  * The routes must work pre-auth — the whole point is a host that holds no
13
19
  * bridge credential yet — so the grant token IS the authorization, exactly
14
20
  * like streamed-transfer tokens (transfers/transfers.ts): random 192-bit, single-use
@@ -56,8 +62,22 @@ export type NodeProvisionGrant = {
56
62
  binaryUsed: boolean;
57
63
  };
58
64
 
65
+ /** The fields an auto grant is missing until the target host reports in. */
66
+ export type NodeProvisionTarget = Pick<
67
+ NodeProvisionGrant,
68
+ "goos" | "goarch" | "binaryPath" | "sha256" | "size" | "version"
69
+ >;
70
+
71
+ /** An auto grant before its target is known — everything but the binary. */
72
+ export type PendingNodeProvision = Omit<
73
+ NodeProvisionGrant,
74
+ keyof NodeProvisionTarget | "scriptUsed" | "binaryUsed"
75
+ >;
76
+
59
77
  export class NodeProvisionStore {
60
78
  private readonly grants = new Map<string, NodeProvisionGrant>();
79
+ /** Auto grants whose target host hasn't fetched the script yet. */
80
+ private readonly pending = new Map<string, PendingNodeProvision>();
61
81
 
62
82
  constructor(private readonly ttlMs = GRANT_TTL_MS) {}
63
83
 
@@ -70,11 +90,7 @@ export class NodeProvisionStore {
70
90
  this.sweep();
71
91
  const full: NodeProvisionGrant = {
72
92
  ...grant,
73
- // Device names are injected into generated shell/PowerShell text —
74
- // keep them to characters that can't break out of a quoted string.
75
- ...(grant.name
76
- ? { name: grant.name.replace(/[^\w .-]+/g, "").slice(0, 64) }
77
- : {}),
93
+ ...sanitizedName(grant.name),
78
94
  token: randomBytes(24).toString("base64url"),
79
95
  createdAt: Date.now(),
80
96
  scriptUsed: false,
@@ -84,6 +100,48 @@ export class NodeProvisionStore {
84
100
  return full;
85
101
  }
86
102
 
103
+ /**
104
+ * Mint an auto grant: no target yet. {@link pin} fills it in when the host
105
+ * fetches the script and reports its own os/arch.
106
+ */
107
+ createAuto(
108
+ grant: Omit<PendingNodeProvision, "token" | "createdAt">,
109
+ ): PendingNodeProvision {
110
+ this.sweep();
111
+ const pending: PendingNodeProvision = {
112
+ ...grant,
113
+ ...sanitizedName(grant.name),
114
+ token: randomBytes(24).toString("base64url"),
115
+ createdAt: Date.now(),
116
+ };
117
+ this.pending.set(pending.token, pending);
118
+ return pending;
119
+ }
120
+
121
+ /** Whether `token` is a live auto grant still waiting for its target. */
122
+ isPending(token: string): boolean {
123
+ this.sweep();
124
+ return this.pending.has(token);
125
+ }
126
+
127
+ /**
128
+ * Bind an auto grant to the binary for its host. It becomes an ordinary
129
+ * grant under the same token (same TTL clock); a second pin is a no-op.
130
+ */
131
+ pin(token: string, target: NodeProvisionTarget): boolean {
132
+ this.sweep();
133
+ const pending = this.pending.get(token);
134
+ if (!pending) return false;
135
+ this.pending.delete(token);
136
+ this.grants.set(token, {
137
+ ...pending,
138
+ ...target,
139
+ scriptUsed: false,
140
+ binaryUsed: false,
141
+ });
142
+ return true;
143
+ }
144
+
87
145
  /** Serve the installer script for a live grant — once. */
88
146
  openScript(token: string): { script: string; filename: string } | null {
89
147
  const grant = this.claim(token, "scriptUsed");
@@ -121,9 +179,20 @@ export class NodeProvisionStore {
121
179
  for (const [token, grant] of this.grants) {
122
180
  if (grant.createdAt < cutoff) this.grants.delete(token);
123
181
  }
182
+ for (const [token, grant] of this.pending) {
183
+ if (grant.createdAt < cutoff) this.pending.delete(token);
184
+ }
124
185
  }
125
186
  }
126
187
 
188
+ /**
189
+ * Device names are injected into generated shell/PowerShell text — keep them
190
+ * to characters that can't break out of a quoted string.
191
+ */
192
+ function sanitizedName(name: string | undefined): { name?: string } {
193
+ return name ? { name: name.replace(/[^\w .-]+/g, "").slice(0, 64) } : {};
194
+ }
195
+
127
196
  /**
128
197
  * Anything that could end, expand, or escape inside a quoted sh,
129
198
  * PowerShell, or cmd.exe string: both quote kinds, `$`, backtick,
@@ -257,6 +326,46 @@ export function installOneLiner(grant: NodeProvisionGrant): string {
257
326
  );
258
327
  }
259
328
 
329
+ /**
330
+ * The two commands for an auto grant — one per shell family, pinned exactly
331
+ * like {@link installOneLiner}. Each appends its host's platform to the
332
+ * script fetch; the bridge maps `Linux`/`Darwin`, `x86_64`/`aarch64`/
333
+ * `armv7l`, and PowerShell's `AMD64`/`ARM64` through the same normalizers
334
+ * the tool arguments use.
335
+ *
336
+ * The PowerShell line carries one `$` (`$env:PROCESSOR_ARCHITECTURE`): from
337
+ * cmd.exe it reaches PowerShell verbatim, and from a PowerShell prompt the
338
+ * outer double quotes expand it first — the same value either way.
339
+ */
340
+ export function autoInstallOneLiners(
341
+ grant: Pick<
342
+ NodeProvisionGrant,
343
+ "bridgeUrl" | "token" | "fingerprint" | "spkiPin"
344
+ >,
345
+ ): { posix: string; windows: string } {
346
+ const url = `${grant.bridgeUrl}/node/install?provision=${grant.token}`;
347
+ const posix = `curl ${curlFlags(grant.spkiPin)} "${sh(url)}&os=$(uname -s)&arch=$(uname -m)" | sh`;
348
+ const psUrl = `('${psq(url)}&os=windows&arch=' + $env:PROCESSOR_ARCHITECTURE)`;
349
+ const windows = grant.fingerprint
350
+ ? `powershell -ExecutionPolicy Bypass -Command "` +
351
+ `${psPinSetup(grant.fingerprint)}; ` +
352
+ `iex ([TalonPin]::Get${psUrl})"`
353
+ : `powershell -ExecutionPolicy Bypass -Command "` +
354
+ `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
355
+ `iwr -UseBasicParsing ${psUrl} | Select-Object -ExpandProperty Content | iex"`;
356
+ return { posix, windows };
357
+ }
358
+
359
+ /**
360
+ * What an auto grant serves when the host's platform has no build or the
361
+ * binary can't be resolved: a script that fails loudly. Valid as both POSIX
362
+ * sh and PowerShell, since which shell is piping it isn't known.
363
+ */
364
+ export function installRefusalScript(reason: string): string {
365
+ const safe = reason.replace(/["'`$\\%\n\r]+/g, " ").slice(0, 300);
366
+ return `echo "talon-node install refused: ${safe}"\nexit 1\n`;
367
+ }
368
+
260
369
  /**
261
370
  * POSIX installer: fetch the binary over the same grant (pinned like the
262
371
  * script fetch), verify its digest, install it next to the node's config,
@@ -235,12 +235,20 @@ export const meshTools: ToolDefinition[] = [
235
235
  {
236
236
  name: "make_node_install_link",
237
237
  description:
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. 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.",
239
239
  schema: {
240
- 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
+ ),
241
246
  arch: z
242
247
  .string()
243
- .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
+ ),
244
252
  name: z
245
253
  .string()
246
254
  .optional()
@@ -251,7 +259,7 @@ export const meshTools: ToolDefinition[] = [
251
259
  .string()
252
260
  .optional()
253
261
  .describe(
254
- "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).",
255
263
  ),
256
264
  },
257
265
  execute: (params, bridge) => bridge("make_node_install_link", params),
@@ -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",
@@ -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
  }