talon-agent 5.14.0 → 5.18.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.
Files changed (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { connectMcpServers, MCPServerStreamableHttp } from "@openai/agents";
27
27
  import type { MCPServer } from "@openai/agents";
28
+ import { gatewayAuthHeaders } from "../../core/engine/gateway-auth.js";
28
29
  import {
29
30
  talonHubUrl,
30
31
  pluginHubUrl,
@@ -185,6 +186,7 @@ async function buildBundle(args: BundleInputs): Promise<OpenAIAgentsMcpBundle> {
185
186
  name: `${frontend}-tools`,
186
187
  url: talonHubUrl(bridgeUrl, frontend, chatId),
187
188
  cacheToolsList: true,
189
+ requestInit: { headers: gatewayAuthHeaders() },
188
190
  }),
189
191
  );
190
192
  }
@@ -197,6 +199,7 @@ async function buildBundle(args: BundleInputs): Promise<OpenAIAgentsMcpBundle> {
197
199
  name: "brave-search",
198
200
  url: pluginHubUrl(bridgeUrl, "brave-search", chatId),
199
201
  cacheToolsList: true,
202
+ requestInit: { headers: gatewayAuthHeaders() },
200
203
  }),
201
204
  );
202
205
  }
@@ -208,6 +211,7 @@ async function buildBundle(args: BundleInputs): Promise<OpenAIAgentsMcpBundle> {
208
211
  name,
209
212
  url: pluginHubUrl(bridgeUrl, name, chatId),
210
213
  cacheToolsList: true,
214
+ requestInit: { headers: gatewayAuthHeaders() },
211
215
  }),
212
216
  );
213
217
  }
@@ -68,6 +68,9 @@ export function createRemoteBackendFactory(
68
68
  return {
69
69
  id,
70
70
  label,
71
+ // The server's built-in shell/file tools can't be withheld per turn,
72
+ // so guest-scoped turns (non-operator senders) are refused.
73
+ guestToolScope: "refused",
71
74
  // The SDK ships as an npm dep but only talks to a server it spawns from
72
75
  // the CLI of the same name (`cross-spawn` → PATH lookup). A present
73
76
  // package with an absent binary fails at the first turn with a bare
@@ -42,6 +42,7 @@ import {
42
42
  describeHubChildExit,
43
43
  } from "../../core/mcp-hub/index.js";
44
44
  import type { RemoteAgentClient } from "./client.js";
45
+ import { gatewayAuthHeaders } from "../../core/engine/gateway-auth.js";
45
46
  import type { RemoteServerState } from "./state.js";
46
47
  import { errMsg } from "./state.js";
47
48
  import type { TalonConfig } from "../../core/config/index.js";
@@ -222,6 +223,7 @@ export async function ensureChatMcpServer<TClient extends RemoteAgentClient>(
222
223
  name: serverName,
223
224
  config: {
224
225
  type: "remote",
226
+ headers: gatewayAuthHeaders(),
225
227
  // Talon's MCP hub serves the chat's tool set in-process over
226
228
  // streamable HTTP — no subprocess on the agent-server side, and
227
229
  // the (frontend, chatId) binding travels in the URL instead of
@@ -305,6 +307,7 @@ export async function ensurePluginMcpServers<TClient extends RemoteAgentClient>(
305
307
  name: serverName,
306
308
  config: {
307
309
  type: "remote",
310
+ headers: gatewayAuthHeaders(),
308
311
  // Hub-managed child, shared across sessions and idle-reaped —
309
312
  // the agent server holds an HTTP connection, not a subprocess.
310
313
  url: pluginHubUrl(bridgeUrl, name, chatId),
@@ -10,7 +10,7 @@
10
10
  * doesn't" drift that motivated the shared-framework refactor.
11
11
  *
12
12
  * Examples:
13
- * group: "[2026-05-15 11:01:23] [Dylan (@dylanneve1)] [msg_id:2485]: actual text"
13
+ * group: "[2026-05-15 11:01:23] [Ada (@ada)] [msg_id:2485]: actual text"
14
14
  * DM: "[2026-05-15 11:01:23] [msg_id:2485] actual text"
15
15
  * DM (no msg_id): "[2026-05-15 11:01:23] actual text"
16
16
  */
@@ -23,10 +23,10 @@ import { formatFullDatetime } from "../../../util/time.js";
23
23
  export type PromptFormatInputs = {
24
24
  /** Raw user text — passed through verbatim. */
25
25
  text: string;
26
- /** Display name of the sender (e.g. "Dylan"). */
26
+ /** Display name of the sender (e.g. "Ada"). */
27
27
  senderName: string;
28
28
  /**
29
- * Platform handle of the sender WITHOUT the leading `@` (e.g. `dylanneve1`).
29
+ * Platform handle of the sender WITHOUT the leading `@` (e.g. `ada`).
30
30
  * Rendered next to the display name in group chats so the model can address
31
31
  * or mention someone correctly — display names are not addressable, handles
32
32
  * are. Absent for users who have no handle set.
package/src/bootstrap.ts CHANGED
@@ -139,6 +139,10 @@ export async function bootstrap(
139
139
  // trimming + brave key come from config; endpoints mount on the
140
140
  // gateway HTTP server).
141
141
  const { initHub } = await import("./core/mcp-hub/index.js");
142
+ const { setNativeToolsEnabled } =
143
+ await import("./core/engine/gateway-actions/native/index.js");
144
+ // The native shell/fs actions only answer when the tool set is on.
145
+ setNativeToolsEnabled(config.nativeTools);
142
146
  initHub({
143
147
  disabledTools: config.disabledTools,
144
148
  disabledToolTags: config.disabledToolTags,
@@ -146,6 +150,10 @@ export async function bootstrap(
146
150
  nativeTools: config.nativeTools,
147
151
  guestDmScope: config.guestDmScope,
148
152
  adminUserId: config.adminUserId,
153
+ operatorIds: [
154
+ ...(config.operatorIds ?? []),
155
+ ...(config.discord?.adminUserIds ?? []).map((id) => `discord:${id}`),
156
+ ],
149
157
  });
150
158
 
151
159
  initWorkspace(config.workspace);
@@ -22,7 +22,12 @@
22
22
 
23
23
  import pc from "picocolors";
24
24
  import { createInterface } from "node:readline/promises";
25
- import { getFrontends, loadConfig } from "../../core/config/index.js";
25
+ import { join } from "node:path";
26
+ import {
27
+ getFrontends,
28
+ loadBackupConfig,
29
+ loadConfig,
30
+ } from "../../core/config/index.js";
26
31
  import { findRunningInstance } from "../../core/daemon/discovery.js";
27
32
  import {
28
33
  collectBackupStatus,
@@ -40,7 +45,9 @@ import {
40
45
  } from "../../core/backup/index.js";
41
46
  import { resolveBackupSettings } from "../../core/backup/plan.js";
42
47
  import { buildSnapshot } from "../../core/backup/snapshot.js";
43
- import { pruneLocal } from "../../core/backup/store.js";
48
+ import { pruneLocal, reconcileIndex } from "../../core/backup/store.js";
49
+ import { generatePassphraseFile } from "../../core/backup/passphrase.js";
50
+ import { dirs } from "../../util/paths.js";
44
51
  import { fetchGateway } from "../daemon-api.js";
45
52
 
46
53
  const USAGE = `
@@ -50,10 +57,13 @@ const USAGE = `
50
57
  ${pc.cyan("list")} [--remote <targetId>] list snapshots
51
58
  ${pc.cyan("show")} <id> everything about one snapshot
52
59
  ${pc.cyan("pin")} <id> | ${pc.cyan("unpin")} <id> keep past retention, or release
53
- ${pc.cyan("restore")} <id> [--from <target>] [--yes] restore (daemon must be stopped)
60
+ ${pc.cyan("restore")} <id> [--from <target>] [--yes] [--allow-unauthenticated]
61
+ restore (daemon must be stopped)
62
+ [--clone] …onto a new machine: relocate sessions + plugin paths
54
63
  ${pc.cyan("prune")} apply the local retention policy
55
64
  ${pc.cyan("targets")} remote targets and their readiness
56
65
  ${pc.cyan("status")} schedule, sizes, targets
66
+ ${pc.cyan("keygen")} [path] new passphrase file (mode 600)
57
67
  `;
58
68
 
59
69
  type Flags = { values: string[]; flags: Map<string, string | true> };
@@ -169,6 +179,9 @@ async function backupNow(flags: Flags): Promise<void> {
169
179
 
170
180
  async function backupList(flags: Flags): Promise<void> {
171
181
  const target = flags.flags.get("remote");
182
+ // The manifests on disk are authoritative: a fresh host with a copied-in
183
+ // backups/ directory has an empty index until something reconciles it.
184
+ await reconcileIndex();
172
185
  const snapshots = await listSnapshots();
173
186
  const filtered =
174
187
  typeof target === "string"
@@ -312,10 +325,23 @@ async function backupRestore(id: string, flags: Flags): Promise<void> {
312
325
  return;
313
326
  }
314
327
  }
315
- const settings = resolveBackupSettings(loadConfig().backup);
328
+ // Only the backup block: on a fresh host the real config is inside the
329
+ // snapshot, and a full load would reject the placeholder one.
330
+ const settings = resolveBackupSettings(loadBackupConfig());
316
331
  const target =
317
332
  typeof from === "string" ? await resolveTarget(from) : undefined;
318
- const report = await restoreSnapshot({ id, settings, target });
333
+ if (typeof from === "string" && !target) {
334
+ process.exitCode = 1;
335
+ return;
336
+ }
337
+ const clone = flags.flags.get("clone") === true;
338
+ const report = await restoreSnapshot({
339
+ id,
340
+ settings,
341
+ target,
342
+ allowUnauthenticated: flags.flags.get("allow-unauthenticated") === true,
343
+ clone,
344
+ });
319
345
  const written = Object.entries(report.written)
320
346
  .map(([root, count]) => `${root} (${count})`)
321
347
  .join(", ");
@@ -330,6 +356,12 @@ async function backupRestore(id: string, flags: Flags): Promise<void> {
330
356
  console.log(
331
357
  ` Database: ${report.databaseReplaced ? "replaced" : "left in place"}\n`,
332
358
  );
359
+ if (clone) {
360
+ console.log(
361
+ ` Clone: config.json paths ${report.configRewritten ? "rewritten for this machine" : "unchanged"}.\n` +
362
+ ` Reinstall fetched plugins from ${pc.cyan("~/.talon/plugins-manifest.json")} (docs/backups.md).\n`,
363
+ );
364
+ }
333
365
  }
334
366
 
335
367
  /**
@@ -354,6 +386,27 @@ async function resolveTarget(id: string): Promise<BackupTarget | undefined> {
354
386
  return target;
355
387
  }
356
388
 
389
+ /** Write a fresh passphrase file. Prints where, never what. */
390
+ async function backupKeygen(path: string | undefined): Promise<void> {
391
+ try {
392
+ const dest = await generatePassphraseFile(
393
+ path ?? join(dirs.root, "backup.key"),
394
+ );
395
+ console.log(
396
+ ` ${pc.green("●")} Wrote a new backup passphrase to ${pc.bold(dest)} (mode 600).\n` +
397
+ ` Enable it in config.json:\n` +
398
+ ` "backup": { "encryption": { "passphraseFile": "${dest}" } }\n` +
399
+ ` Keep a copy OFF this machine — encrypted snapshots cannot be\n` +
400
+ ` restored without it.\n`,
401
+ );
402
+ } catch (err) {
403
+ console.log(
404
+ ` ${pc.red("●")} ${err instanceof Error ? err.message : String(err)}\n`,
405
+ );
406
+ process.exitCode = 1;
407
+ }
408
+ }
409
+
357
410
  // ── Entry ───────────────────────────────────────────────────────────────────
358
411
 
359
412
  export async function runBackupCommand(argv: readonly string[]): Promise<void> {
@@ -386,6 +439,9 @@ export async function runBackupCommand(argv: readonly string[]): Promise<void> {
386
439
  case "targets":
387
440
  await backupTargets();
388
441
  return;
442
+ case "keygen":
443
+ await backupKeygen(id);
444
+ return;
389
445
  case "status":
390
446
  case undefined:
391
447
  await backupStatus();
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `talon mesh` — per-device credentials from the outside.
3
+ *
4
+ * talon mesh [list] every credential + devices still on
5
+ * the shared token
6
+ * talon mesh revoke <device|credId> revoke now; live sessions drop
7
+ * talon mesh rotate <device> the device swaps credentials on its
8
+ * next heartbeat (old one: 7-day grace)
9
+ * talon mesh scopes <device> <list> e.g. device,client,operator
10
+ *
11
+ * Everything goes through the running daemon's loopback gateway
12
+ * (core/engine/gateway-routes.ts → core/mesh/credentials/admin.ts): the
13
+ * daemon owns the store and the live sessions a revocation must drop, so
14
+ * editing the file behind its back could never take effect in time.
15
+ */
16
+
17
+ import pc from "picocolors";
18
+ import { fetchGateway, requireGatewayPort } from "../daemon-api.js";
19
+ import type { DeviceCredential } from "../../core/mesh/credentials/index.js";
20
+
21
+ const USAGE = `
22
+ ${pc.bold("talon mesh")} — per-device mesh credentials
23
+
24
+ ${pc.cyan("list")} credentials, scopes, last use (default)
25
+ ${pc.cyan("revoke")} <device|credential-id> revoke now; drops its live sessions
26
+ ${pc.cyan("rotate")} <device> re-issue on the device's next heartbeat
27
+ ${pc.cyan("scopes")} <device> <scope,scope> set scopes: device, client, operator
28
+ `;
29
+
30
+ type Overview = {
31
+ ok: boolean;
32
+ error?: string;
33
+ credentials?: DeviceCredential[];
34
+ legacyDevices?: { deviceId: string; lastSeen: number }[];
35
+ legacySharedToken?: boolean;
36
+ };
37
+
38
+ type AdminReply = { ok: boolean; text?: string; error?: string };
39
+
40
+ function ago(ts: number | undefined, now: number): string {
41
+ if (ts === undefined) return "never";
42
+ const s = Math.max(0, Math.round((now - ts) / 1000));
43
+ if (s < 90) return `${s}s ago`;
44
+ if (s < 5400) return `${Math.round(s / 60)}m ago`;
45
+ if (s < 172_800) return `${Math.round(s / 3600)}h ago`;
46
+ return `${Math.round(s / 86_400)}d ago`;
47
+ }
48
+
49
+ function stateOf(c: DeviceCredential, now: number): string {
50
+ if (c.revokedAt !== undefined) return pc.dim(`revoked (${c.revokeReason})`);
51
+ if (c.expiresAt !== undefined && now >= c.expiresAt) return pc.dim("expired");
52
+ if (c.supersededBy) return pc.yellow(`superseded by ${c.supersededBy}`);
53
+ if (c.rotateRequestedAt !== undefined) return pc.yellow("rotation pending");
54
+ if (c.deviceId === null) return pc.yellow("unbound (awaiting first use)");
55
+ return pc.green("active");
56
+ }
57
+
58
+ function renderOverview(o: Overview): void {
59
+ const now = Date.now();
60
+ const creds = o.credentials ?? [];
61
+ console.log(`\n ${pc.bold("Mesh credentials")}\n`);
62
+ if (creds.length === 0) console.log(` ${pc.dim("(none issued yet)")}`);
63
+ for (const c of creds) {
64
+ console.log(
65
+ ` ${pc.cyan(c.id)} ${c.deviceId ?? pc.dim("—")} [${c.scopes.join(", ")}] ${stateOf(c, now)}`,
66
+ );
67
+ console.log(
68
+ pc.dim(
69
+ ` via ${c.origin}, issued ${ago(c.createdAt, now)}, last used ${ago(c.lastUsedAt, now)}`,
70
+ ),
71
+ );
72
+ }
73
+ const legacy = o.legacyDevices ?? [];
74
+ if (legacy.length > 0) {
75
+ console.log(`\n ${pc.yellow("Still on the shared native.token:")}`);
76
+ for (const d of legacy) {
77
+ console.log(` ${d.deviceId} ${pc.dim(`seen ${ago(d.lastSeen, now)}`)}`);
78
+ }
79
+ }
80
+ console.log(
81
+ `\n Legacy shared token from remote clients: ${
82
+ o.legacySharedToken ? pc.yellow("accepted") : pc.green("refused")
83
+ }\n`,
84
+ );
85
+ }
86
+
87
+ async function adminOp(
88
+ port: number,
89
+ body: Record<string, unknown>,
90
+ ): Promise<void> {
91
+ const reply = (await fetchGateway(port, "/mesh/credentials", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify(body),
95
+ })) as AdminReply;
96
+ if (reply.ok) {
97
+ console.log(`\n ${pc.green("✔")} ${reply.text}\n`);
98
+ } else {
99
+ console.error(`\n ${pc.red("✖")} ${reply.error}\n`);
100
+ process.exitCode = 1;
101
+ }
102
+ }
103
+
104
+ export async function runMeshCommand(args: string[]): Promise<void> {
105
+ const [sub = "list", target, scopes] = args;
106
+ if (sub === "help" || sub === "--help" || sub === "-h") {
107
+ console.log(USAGE);
108
+ return;
109
+ }
110
+ const known = ["list", "revoke", "rotate", "scopes"];
111
+ if (!known.includes(sub)) {
112
+ console.error(` Unknown mesh command: ${sub}\n${USAGE}`);
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ if (sub !== "list" && !target) {
117
+ console.error(
118
+ ` ${pc.red("✖")} talon mesh ${sub} needs a device.\n${USAGE}`,
119
+ );
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ const port = await requireGatewayPort();
124
+ if (port === null) {
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ if (sub === "list") {
129
+ renderOverview((await fetchGateway(port, "/mesh/credentials")) as Overview);
130
+ return;
131
+ }
132
+ await adminOp(port, { op: sub, device: target, scopes });
133
+ }
package/src/cli/config.ts CHANGED
@@ -46,6 +46,8 @@ export type Config = {
46
46
  pulse: boolean;
47
47
  pulseIntervalMs: number;
48
48
  adminUserId?: number;
49
+ /** Telegram DM allowlist; defaults to `[adminUserId]` when set up. */
50
+ allowedUsers?: number[];
49
51
  apiId?: number;
50
52
  apiHash?: string;
51
53
  maxMessageLength: number;
@@ -222,7 +224,7 @@ export function isConfigured(config: Config): boolean {
222
224
  ? config.frontend
223
225
  : [config.frontend];
224
226
  return fes.every((fe) => {
225
- if (fe === "telegram") return !!config.botToken;
227
+ if (fe === "telegram") return !!config.botToken && !!config.adminUserId;
226
228
  if (fe === "teams") return !!config.teamsWebhookUrl;
227
229
  if (fe === "discord") return !!config.discord?.botToken;
228
230
  // WhatsApp's credentials live in ~/.talon/whatsapp-auth/, not the
@@ -6,19 +6,41 @@
6
6
 
7
7
  import pc from "picocolors";
8
8
  import { findRunningInstance } from "../core/daemon/discovery.js";
9
+ import {
10
+ gatewayAuthHeaders,
11
+ gatewayTokenPath,
12
+ readGatewayToken,
13
+ } from "../core/engine/gateway-auth.js";
9
14
 
10
15
  const REQUEST_TIMEOUT_MS = 3_000;
11
16
 
17
+ /**
18
+ * Call a gateway endpoint as the operator: every request carries the gateway
19
+ * token (read from ~/.talon/keys/gateway-token), and a POST always declares a
20
+ * JSON body — the gateway refuses anything else.
21
+ */
12
22
  export async function fetchGateway(
13
23
  port: number,
14
24
  path: string,
15
25
  init?: RequestInit,
16
26
  timeoutMs: number = REQUEST_TIMEOUT_MS,
17
27
  ): Promise<unknown> {
28
+ const isPost = init?.method === "POST";
18
29
  const response = await fetch(`http://127.0.0.1:${port}${path}`, {
19
30
  ...init,
31
+ ...(isPost && init?.body === undefined ? { body: "{}" } : {}),
32
+ headers: {
33
+ ...(isPost ? { "Content-Type": "application/json" } : {}),
34
+ ...(init?.headers as Record<string, string> | undefined),
35
+ ...gatewayAuthHeaders(readGatewayToken()),
36
+ },
20
37
  signal: AbortSignal.timeout(timeoutMs),
21
38
  });
39
+ if (response.status === 401) {
40
+ throw new Error(
41
+ `gateway refused the request (401) — is ${gatewayTokenPath()} readable by this user?`,
42
+ );
43
+ }
22
44
  if (!response.ok) throw new Error(`gateway answered ${response.status}`);
23
45
  return response.json();
24
46
  }
package/src/cli/index.ts CHANGED
@@ -39,6 +39,7 @@ import { runSkillCommand } from "./skill.js";
39
39
  import { runMemoryCommand } from "./memory.js";
40
40
  import { mainMenu } from "./menu.js";
41
41
  import { runBackupCommand } from "./commands/backup.js";
42
+ import { runMeshCommand } from "./commands/mesh.js";
42
43
 
43
44
  export * from "./context.js";
44
45
  export * from "./config.js";
@@ -62,6 +63,7 @@ const CLI_COMMANDS = [
62
63
  "skill",
63
64
  "memory",
64
65
  "backup",
66
+ "mesh",
65
67
  ];
66
68
 
67
69
  /** `talon events [-f] [--history [N]]` → the tail options. */
@@ -110,6 +112,9 @@ function printHelp(): void {
110
112
  console.log(
111
113
  ` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
112
114
  );
115
+ console.log(
116
+ ` ${pc.cyan("mesh")} Device credentials (list/revoke/rotate/scopes)`,
117
+ );
113
118
  console.log(` ${pc.cyan("config")} View/edit configuration`);
114
119
  console.log(` ${pc.cyan("logs")} Tail log file`);
115
120
  console.log(` ${pc.cyan("doctor")} Validate environment`);
@@ -173,6 +178,7 @@ const COMMANDS: Record<string, CommandHandler> = {
173
178
  kill: (args) => killTask(args[0]),
174
179
  events: (args) => showEvents(eventsOptions(args)),
175
180
  backup: (args) => runBackupCommand(args),
181
+ mesh: (args) => runMeshCommand(args),
176
182
  plugin: (args) => runPluginCommand(args),
177
183
  skill: (args) => runSkillCommand(args),
178
184
  memory: (args) => runMemoryCommand(args),
@@ -10,13 +10,15 @@
10
10
  * 4. anything else → { kind: "other" } — the caller
11
11
  * decides (plugins treat it as an npm spec, skills reject it)
12
12
  *
13
- * Cloning always uses `--depth=1` (installs never need history) and spawns
13
+ * Cloning always uses `--depth=1` (installs never need history), ends the
14
+ * options with `--` so a URL can never be read as a git flag, and reports
15
+ * the commit it got so the install can be pinned/audited later. It spawns
14
16
  * `git`/`npm` via cross-spawn, which resolves the `.cmd`/`.exe` shims on
15
17
  * Windows — never assume a POSIX shell here.
16
18
  */
17
19
 
18
20
  import crossSpawn from "cross-spawn";
19
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
22
  import { tmpdir } from "node:os";
21
23
  import { join, resolve } from "node:path";
22
24
 
@@ -91,16 +93,49 @@ export function runTool(
91
93
  }
92
94
 
93
95
  export type CloneOutcome =
94
- { ok: true; dir: string; cleanup: () => void } | { ok: false; error: string };
96
+ | { ok: true; dir: string; commit?: string; cleanup: () => void }
97
+ | { ok: false; error: string };
98
+
99
+ /** The checked-out commit of a clone, when git can tell us. */
100
+ function headCommit(dir: string): string | undefined {
101
+ const result = crossSpawn.sync("git", ["-C", dir, "rev-parse", "HEAD"], {
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ });
104
+ const sha = result.status === 0 ? result.stdout?.toString().trim() : "";
105
+ return sha && /^[0-9a-f]{40,64}$/.test(sha) ? sha : undefined;
106
+ }
95
107
 
96
108
  /** Shallow-clone into a fresh temp directory. Caller must run `cleanup`. */
97
109
  export function cloneShallow(url: string): CloneOutcome {
110
+ // `--` below already stops option parsing; refusing a leading dash too
111
+ // means a hostile "URL" never even reaches git.
112
+ if (url.startsWith("-")) {
113
+ return { ok: false, error: `Refusing a git URL that starts with "-"` };
114
+ }
98
115
  const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
99
116
  const cleanup = () => rmSync(dir, { recursive: true, force: true });
100
- const outcome = runTool("git", ["clone", "--depth=1", url, dir]);
117
+ const outcome = runTool("git", ["clone", "--depth=1", "--", url, dir]);
101
118
  if (!outcome.ok) {
102
119
  cleanup();
103
120
  return { ok: false, error: `Clone failed: ${outcome.error}` };
104
121
  }
105
- return { ok: true, dir, cleanup };
122
+ return { ok: true, dir, commit: headCommit(dir), cleanup };
123
+ }
124
+
125
+ /** The file a git-installed plugin keeps its provenance in. */
126
+ const INSTALL_RECORD = ".talon-install.json";
127
+
128
+ /** Write where an install came from and exactly which commit it is. */
129
+ export function writeInstallRecord(
130
+ dir: string,
131
+ record: { source: string; subpath?: string; commit?: string },
132
+ ): void {
133
+ writeFileSync(
134
+ join(dir, INSTALL_RECORD),
135
+ JSON.stringify(
136
+ { ...record, installedAt: new Date().toISOString() },
137
+ null,
138
+ 2,
139
+ ) + "\n",
140
+ );
106
141
  }
package/src/cli/plugin.ts CHANGED
@@ -25,6 +25,7 @@ import { fetchGateway } from "./daemon-api.js";
25
25
  import { loadConfig, saveConfig, type Config } from "./config.js";
26
26
  import {
27
27
  cloneShallow,
28
+ writeInstallRecord,
28
29
  resolveSource,
29
30
  runTool,
30
31
  type ResolvedSource,
@@ -258,7 +259,16 @@ function installFromGit(
258
259
  }
259
260
  // An install is a snapshot, not a checkout — updates go through
260
261
  // `install --force`, so the clone's history has no business here.
262
+ // What it WAS a snapshot of is kept, so the exact commit is auditable.
261
263
  rmSync(join(stage, ".git"), { recursive: true, force: true });
264
+ writeInstallRecord(stage, {
265
+ source: source.url,
266
+ ...(source.subpath ? { subpath: source.subpath } : {}),
267
+ ...(clone.commit ? { commit: clone.commit } : {}),
268
+ });
269
+ if (clone.commit) {
270
+ console.log(` ${pc.dim(`Commit ${clone.commit}`)}`);
271
+ }
262
272
 
263
273
  if (existsSync(join(stage, "package.json"))) {
264
274
  console.log(` ${pc.dim("Installing dependencies…")}`);
package/src/cli/setup.ts CHANGED
@@ -94,6 +94,20 @@ type TelegramAnswers = Pick<
94
94
  "botToken" | "adminId" | "apiId" | "apiHash"
95
95
  >;
96
96
 
97
+ /**
98
+ * The Telegram admin id is required: it is the bot's owner (admin commands)
99
+ * and the default DM allowlist. The daemon refuses to start without it.
100
+ */
101
+ export function validateTelegramAdminId(
102
+ value: string | undefined,
103
+ ): string | undefined {
104
+ const v = value?.trim() ?? "";
105
+ if (!v) return "Required — the bot answers only its admin (and allowedUsers)";
106
+ if (!/^\d+$/.test(v) || Number(v) <= 0 || !Number.isSafeInteger(Number(v)))
107
+ return "Must be your numeric Telegram user ID";
108
+ return undefined;
109
+ }
110
+
97
111
  async function askTelegram(config: Config): Promise<TelegramAnswers> {
98
112
  const botToken = await askOrExit(
99
113
  p.text({
@@ -110,8 +124,9 @@ async function askTelegram(config: Config): Promise<TelegramAnswers> {
110
124
  const adminId = await askOrExit(
111
125
  p.text({
112
126
  message: "Your Telegram user ID",
113
- placeholder: "optional — message @userinfobot to find yours",
127
+ placeholder: "message @userinfobot to find yours",
114
128
  initialValue: config.adminUserId ? String(config.adminUserId) : "",
129
+ validate: validateTelegramAdminId,
115
130
  }),
116
131
  );
117
132
 
@@ -619,6 +634,34 @@ export type SetupAnswers = {
619
634
  whatsapp?: Config["whatsapp"];
620
635
  };
621
636
 
637
+ /**
638
+ * Admin + DM allowlist for the saved config. With Telegram on, the admin id
639
+ * is the one just entered, and an empty allowlist defaults to that admin —
640
+ * a fresh bot answers its owner and no one else. With Telegram off, both are
641
+ * left as they were (other surfaces read `adminUserId` too).
642
+ */
643
+ function telegramAccess(
644
+ existing: Config,
645
+ answers: SetupAnswers,
646
+ telegramOn: boolean,
647
+ ): Pick<Config, "adminUserId" | "allowedUsers"> {
648
+ const adminUserId = answers.adminId
649
+ ? parseInt(answers.adminId, 10) || undefined
650
+ : undefined;
651
+ if (!telegramOn || adminUserId === undefined) {
652
+ return {
653
+ adminUserId: adminUserId ?? existing.adminUserId,
654
+ allowedUsers: existing.allowedUsers,
655
+ };
656
+ }
657
+ return {
658
+ adminUserId,
659
+ allowedUsers: existing.allowedUsers?.length
660
+ ? existing.allowedUsers
661
+ : [adminUserId],
662
+ };
663
+ }
664
+
622
665
  /**
623
666
  * Fold the wizard's answers onto the existing config.
624
667
  *
@@ -674,9 +717,7 @@ export function buildSetupConfig(
674
717
  concurrency: existing.concurrency,
675
718
  pulse: answers.pulse,
676
719
  pulseIntervalMs: existing.pulseIntervalMs,
677
- adminUserId: answers.adminId
678
- ? parseInt(answers.adminId, 10) || undefined
679
- : undefined,
720
+ ...telegramAccess(existing, answers, on("telegram")),
680
721
  apiId: answers.apiId,
681
722
  apiHash: answers.apiHash,
682
723
  maxMessageLength: existing.maxMessageLength,
package/src/cli/skill.ts CHANGED
@@ -171,6 +171,9 @@ async function cmdInstall(args: string[]): Promise<void> {
171
171
  return;
172
172
  }
173
173
  installed = installAll(dirs, force);
174
+ if (clone.commit) {
175
+ console.log(` ${pc.dim(`From ${resolved.url} @ ${clone.commit}`)}`);
176
+ }
174
177
  } finally {
175
178
  clone.cleanup();
176
179
  }