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
@@ -9,7 +9,7 @@ import { resolve } from "node:path";
9
9
  import {
10
10
  readBodyLimited,
11
11
  ResponseTooLargeError,
12
- } from "../../../util/http-body.js";
12
+ } from "../../../../util/http-body.js";
13
13
  import {
14
14
  advertisedBinaryKind,
15
15
  decodeText,
@@ -18,9 +18,11 @@ import {
18
18
  isHtmlContent,
19
19
  isTextContent,
20
20
  matchesBinaryKind,
21
- } from "../../tools/content/web-content.js";
22
- import { dirs } from "../../../util/paths.js";
23
- import type { SharedActionHandlers } from "./types.js";
21
+ } from "../../../tools/content/web-content.js";
22
+ import { dirs } from "../../../../util/paths.js";
23
+ import { getPoolConfig } from "../../backend-controller/index.js";
24
+ import type { SharedActionHandlers } from "../types.js";
25
+ import { BlockedUrlError, guardedFetch } from "./guard.js";
24
26
 
25
27
  const MAX_RESPONSE_MB = 50;
26
28
  const MAX_RESPONSE_BYTES = MAX_RESPONSE_MB * 1024 * 1024;
@@ -32,24 +34,60 @@ function capText(text: string): string {
32
34
  return `${text.slice(0, MAX_TEXT_CHARS)}\n\n[Content truncated at ${MAX_TEXT_CHARS} characters]`;
33
35
  }
34
36
 
37
+ /** Reject anything that isn't a well-formed http(s) URL. */
38
+ function urlError(url: string): string | undefined {
39
+ if (!url) return "Missing URL";
40
+ try {
41
+ const parsed = new URL(url);
42
+ if (!["http:", "https:"].includes(parsed.protocol)) {
43
+ return "URL must use http or https protocol";
44
+ }
45
+ } catch {
46
+ return "Invalid URL";
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /** Turn a text-ish body into the tool's text result. */
52
+ function textResult(
53
+ mimeType: string,
54
+ buffer: Buffer,
55
+ ct: string,
56
+ ): { ok: true; text: string } {
57
+ const trimmed = decodeText(buffer, ct).trim();
58
+ if (!trimmed) return { ok: true, text: "(Page has no readable content)" };
59
+
60
+ // extractText is a DOM extractor — running it on JSON/XML/JavaScript/
61
+ // plain text strips small payloads like {"status":"ok"} to nothing,
62
+ // so only HTML (declared or sniffed) goes through it.
63
+ if (!isHtmlContent(mimeType, trimmed)) {
64
+ return { ok: true, text: capText(trimmed) };
65
+ }
66
+ const text = extractText(trimmed, Number.POSITIVE_INFINITY);
67
+ if (text.length < 20)
68
+ return { ok: true, text: "(Page has no readable content)" };
69
+ return { ok: true, text: capText(text) };
70
+ }
71
+
35
72
  export const fetchUrlHandlers: SharedActionHandlers = {
36
73
  fetch_url: async (body) => {
37
74
  const url = String(body.url ?? "");
38
- if (!url) return { ok: false, error: "Missing URL" };
75
+ const invalid = urlError(url);
76
+ if (invalid) return { ok: false, error: invalid };
39
77
  try {
40
- const parsed = new URL(url);
41
- if (!["http:", "https:"].includes(parsed.protocol)) {
42
- return { ok: false, error: "URL must use http or https protocol" };
43
- }
44
- } catch {
45
- return { ok: false, error: "Invalid URL" };
46
- }
47
- try {
48
- const resp = await fetch(url, {
49
- signal: AbortSignal.timeout(15_000),
50
- headers: { "User-Agent": "Talon/1.0" },
51
- redirect: "follow",
52
- });
78
+ // Every hop is checked against private/loopback/link-local ranges
79
+ // (see guard.ts) unless the operator opted out for local use.
80
+ const resp = await guardedFetch(
81
+ url,
82
+ {
83
+ signal: AbortSignal.timeout(15_000),
84
+ headers: { "User-Agent": "Talon/1.0" },
85
+ },
86
+ {
87
+ allowPrivateNetworks:
88
+ getPoolConfig()?.fetchUrl?.allowPrivateNetworks === true,
89
+ },
90
+ );
53
91
  if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
54
92
  const ct = resp.headers.get("content-type") ?? "";
55
93
 
@@ -78,20 +116,7 @@ export const fetchUrlHandlers: SharedActionHandlers = {
78
116
  }
79
117
 
80
118
  if (isTextContent(mimeType, buffer)) {
81
- const trimmed = decodeText(buffer, ct).trim();
82
- if (!trimmed)
83
- return { ok: true, text: "(Page has no readable content)" };
84
-
85
- // extractText is a DOM extractor — running it on JSON/XML/JavaScript/
86
- // plain text strips small payloads like {"status":"ok"} to nothing,
87
- // so only HTML (declared or sniffed) goes through it.
88
- if (!isHtmlContent(mimeType, trimmed)) {
89
- return { ok: true, text: capText(trimmed) };
90
- }
91
- const text = extractText(trimmed, Number.POSITIVE_INFINITY);
92
- if (text.length < 20)
93
- return { ok: true, text: "(Page has no readable content)" };
94
- return { ok: true, text: capText(text) };
119
+ return textResult(mimeType, buffer, ct);
95
120
  }
96
121
 
97
122
  if (buffer.length === 0)
@@ -125,6 +150,9 @@ export const fetchUrlHandlers: SharedActionHandlers = {
125
150
  text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").`,
126
151
  };
127
152
  } catch (err) {
153
+ if (err instanceof BlockedUrlError) {
154
+ return { ok: false, error: err.message };
155
+ }
128
156
  return {
129
157
  ok: false,
130
158
  error: `Fetch failed: ${err instanceof Error ? err.message : err}`,
@@ -28,7 +28,7 @@ import type { ActionResult } from "../../types.js";
28
28
  import type { Backend } from "../../agent-runtime/capabilities.js";
29
29
  import type { SharedActionHandlers } from "./types.js";
30
30
  import { historyHandlers } from "./history.js";
31
- import { fetchUrlHandlers } from "./fetch-url.js";
31
+ import { fetchUrlHandlers } from "./fetch-url/index.js";
32
32
  import { cronHandlers } from "./cron.js";
33
33
  import { triggerHandlers } from "./triggers.js";
34
34
  import { agentContextActions, agentHandlers } from "./agents/index.js";
@@ -47,7 +47,7 @@ import {
47
47
  whatsappAccountHandlers,
48
48
  whatsappAccountChatFreeActions,
49
49
  } from "./whatsapp-account.js";
50
- import { nativeHandlers } from "./native/index.js";
50
+ import { nativeActionRefusal, nativeHandlers } from "./native/index.js";
51
51
  import { backupChatFreeActions, backupHandlers } from "./backup/index.js";
52
52
 
53
53
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
@@ -98,6 +98,8 @@ export async function handleSharedAction(
98
98
  const action = body.action as string;
99
99
  const handler = handlers[action];
100
100
  if (!handler) return null; // not a shared action — delegate to frontend
101
+ const refusal = nativeActionRefusal(action);
102
+ if (refusal) return refusal;
101
103
  return handler(body, chatId, backend, chatKey);
102
104
  }
103
105
 
@@ -36,6 +36,7 @@ import { readHandlers } from "./read.js";
36
36
  import { writeHandlers } from "./write.js";
37
37
  import { searchHandlers } from "./search.js";
38
38
  import type { SharedActionHandlers } from "../types.js";
39
+ import type { ActionResult } from "../../../types.js";
39
40
 
40
41
  export const nativeHandlers: SharedActionHandlers = {
41
42
  ...teleportHandlers,
@@ -44,3 +45,26 @@ export const nativeHandlers: SharedActionHandlers = {
44
45
  ...writeHandlers,
45
46
  ...searchHandlers,
46
47
  };
48
+
49
+ /**
50
+ * Whether the native tool actions may run at all. Off unless
51
+ * `config.nativeTools` turns them on (bootstrap calls the setter): with the
52
+ * option off the tools are never offered to the model, so a request for
53
+ * one can only come from outside the daemon's own tool surface.
54
+ */
55
+ let nativeToolsEnabled = false;
56
+
57
+ export function setNativeToolsEnabled(enabled: boolean): void {
58
+ nativeToolsEnabled = enabled;
59
+ }
60
+
61
+ /** The refusal for a native action while native tools are off, else null. */
62
+ export function nativeActionRefusal(action: string): ActionResult | null {
63
+ if (nativeToolsEnabled || !Object.hasOwn(nativeHandlers, action)) {
64
+ return null;
65
+ }
66
+ return {
67
+ ok: false,
68
+ error: `${action} is unavailable: native tools are disabled (set "nativeTools": true in config.json to enable them).`,
69
+ };
70
+ }
@@ -5,7 +5,7 @@
5
5
  * A chat-free shared action, for the same reason `send_via` is one: the
6
6
  * account surface belongs to the WhatsApp *connection*, not to any
7
7
  * conversation, and the session driving it usually lives somewhere else
8
- * (Dylan's Telegram DM, or a heartbeat run with no ambient chat at
8
+ * (Ada's Telegram DM, or a heartbeat run with no ambient chat at
9
9
  * all). Routing it through the normal chat_id path would demand a
10
10
  * WhatsApp chat that need not exist.
11
11
  *
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Gateway auth — the credential and request guard in front of the local
3
+ * action gateway (and the MCP hub mounted on it).
4
+ *
5
+ * The gateway binds 127.0.0.1, but loopback is not a trust boundary on its
6
+ * own: other accounts on the same host can connect to it, and a browser can
7
+ * be steered at it (cross-site requests, DNS rebinding). So every request
8
+ * passes three checks before routing:
9
+ *
10
+ * - Host must name loopback on the gateway's own port. A rebinding
11
+ * hostname resolves to 127.0.0.1 but still carries its own Host.
12
+ * - No `Origin` header. Talon's clients (Node/Bun fetch, the Claude/Codex
13
+ * CLIs, MCP SDK clients) never send one; browsers always do on the
14
+ * requests that matter, and scripts cannot suppress it.
15
+ * - POSTs must declare `Content-Type: application/json`, which a plain
16
+ * HTML form cannot produce without a CORS preflight.
17
+ *
18
+ * and every route except `/health` requires the gateway token as
19
+ * `Authorization: Bearer <token>`, compared in constant time.
20
+ *
21
+ * The token is minted once (32 random bytes) into ~/.talon/keys/gateway-token
22
+ * with owner-only permissions and stays stable across restarts, so agent
23
+ * servers and CLIs configured by a previous boot keep working. The daemon
24
+ * also exports it as TALON_GATEWAY_TOKEN, so every process it spawns (MCP
25
+ * children, the Claude/Codex CLIs, plugins) inherits it without the value
26
+ * ever appearing on a command line. Delete the file and restart to rotate.
27
+ */
28
+
29
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
30
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import type { IncomingMessage } from "node:http";
32
+ import { dirname, resolve } from "node:path";
33
+ import { dirs } from "../../util/paths.js";
34
+
35
+ /** Env var carrying the token to child processes (plugins, agent CLIs). */
36
+ export const GATEWAY_TOKEN_ENV = "TALON_GATEWAY_TOKEN";
37
+
38
+ const TOKEN_FILE = "gateway-token";
39
+ /** 32 random bytes → 43 base64url chars. */
40
+ const TOKEN_BYTES = 32;
41
+
42
+ let cachedToken: string | null = null;
43
+
44
+ /** Path of the persisted token file. */
45
+ export function gatewayTokenPath(dir: string = dirs.keys): string {
46
+ return resolve(dir, TOKEN_FILE);
47
+ }
48
+
49
+ function readTokenFile(path: string): string | undefined {
50
+ try {
51
+ const value = readFileSync(path, "utf-8").trim();
52
+ return value || undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ function mintTokenFile(path: string): string {
59
+ const token = randomBytes(TOKEN_BYTES).toString("base64url");
60
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
61
+ writeFileSync(path, `${token}\n`, { mode: 0o600 });
62
+ chmodSync(path, 0o600); // `mode` above is ignored when the file exists
63
+ return token;
64
+ }
65
+
66
+ /**
67
+ * The gateway token, for the process that SERVES the gateway (and for
68
+ * in-process callers). Resolution: an inherited TALON_GATEWAY_TOKEN, then
69
+ * the key file, else a freshly minted key file. The result is exported to
70
+ * `process.env` so children spawned afterwards inherit it.
71
+ */
72
+ export function gatewayToken(): string {
73
+ if (!cachedToken) {
74
+ const inherited = process.env[GATEWAY_TOKEN_ENV]?.trim();
75
+ const path = inherited ? "" : gatewayTokenPath();
76
+ cachedToken = inherited || readTokenFile(path) || mintTokenFile(path);
77
+ }
78
+ // Re-asserted on every call: children spawned from here on must see the
79
+ // token even if something cleared the variable in between.
80
+ process.env[GATEWAY_TOKEN_ENV] = cachedToken;
81
+ return cachedToken;
82
+ }
83
+
84
+ /**
85
+ * The gateway token for a CLIENT of a running daemon (CLI commands): the
86
+ * inherited env var or the key file, never minting one. Undefined when the
87
+ * daemon has not provisioned a token yet.
88
+ */
89
+ export function readGatewayToken(): string | undefined {
90
+ return (
91
+ process.env[GATEWAY_TOKEN_ENV]?.trim() || readTokenFile(gatewayTokenPath())
92
+ );
93
+ }
94
+
95
+ /** Header map authenticating a request to the gateway. */
96
+ export function gatewayAuthHeaders(
97
+ token: string | undefined = gatewayToken(),
98
+ ): Record<string, string> {
99
+ return token ? { Authorization: `Bearer ${token}` } : {};
100
+ }
101
+
102
+ /** Test seam: forget the cached token so the next call re-resolves it. */
103
+ export function resetGatewayTokenCache(): void {
104
+ cachedToken = null;
105
+ }
106
+
107
+ // ── Request guard ───────────────────────────────────────────────────────────
108
+
109
+ /** A refused request: HTTP status plus a short, non-sensitive reason. */
110
+ export type GatewayRefusal = { status: number; error: string };
111
+
112
+ const LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
113
+
114
+ function digest(value: string): Buffer {
115
+ return createHash("sha256").update(value).digest();
116
+ }
117
+
118
+ /** Constant-time check of the request's bearer token against `expected`. */
119
+ export function hasValidGatewayToken(
120
+ req: IncomingMessage,
121
+ expected: string,
122
+ ): boolean {
123
+ const header = req.headers.authorization;
124
+ if (typeof header !== "string") return false;
125
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(header);
126
+ if (!match) return false;
127
+ // Hash both sides so the comparison is length-independent.
128
+ return timingSafeEqual(digest(match[1]!), digest(expected));
129
+ }
130
+
131
+ function isLoopbackHost(host: string | undefined, port: number): boolean {
132
+ if (typeof host !== "string") return false;
133
+ const normalized = host.trim().toLowerCase();
134
+ return LOOPBACK_HOSTS.some((name) => normalized === `${name}:${port}`);
135
+ }
136
+
137
+ function isJsonContentType(value: string | undefined): boolean {
138
+ if (typeof value !== "string") return false;
139
+ const mediaType = value.split(";")[0]!.trim().toLowerCase();
140
+ return mediaType === "application/json";
141
+ }
142
+
143
+ /**
144
+ * Transport-level checks every request must pass, `/health` included:
145
+ * loopback Host on the bound port, no browser Origin, JSON POST bodies.
146
+ */
147
+ export function checkGatewayTransport(
148
+ req: IncomingMessage,
149
+ port: number,
150
+ ): GatewayRefusal | null {
151
+ if (req.headers.origin !== undefined) {
152
+ return { status: 403, error: "Browser requests are not accepted" };
153
+ }
154
+ if (!isLoopbackHost(req.headers.host, port)) {
155
+ return { status: 403, error: "Host not allowed" };
156
+ }
157
+ if (
158
+ req.method === "POST" &&
159
+ !isJsonContentType(req.headers["content-type"])
160
+ ) {
161
+ return { status: 415, error: "Content-Type must be application/json" };
162
+ }
163
+ return null;
164
+ }
@@ -1,19 +1,35 @@
1
1
  /**
2
2
  * The action gateway's HTTP routes — declared once, in order, rather than
3
- * implied by where an `if` sits in `Gateway.start`. Every route shares the
4
- * 127.0.0.1 trust boundary; the table is the list a reviewer reads.
3
+ * implied by where an `if` sits in `Gateway.start`. Every request first
4
+ * passes the transport guard (loopback Host, no browser Origin, JSON POST
5
+ * bodies) and every route except `/health` requires the gateway token —
6
+ * see gateway-auth.ts. The table is the list a reviewer reads.
5
7
  */
6
8
  import type { IncomingMessage, Server, ServerResponse } from "node:http";
7
9
  import { bus } from "../bus/index.js";
8
10
  import { taskTable } from "../tasks/index.js";
9
11
  import { agentRegistry } from "../agents/index.js";
10
12
  import { handleHubRequest, HUB_PATH_PREFIX } from "../mcp-hub/index.js";
13
+ import { getMeshService } from "../mesh/index.js";
14
+ import {
15
+ credentialAdmin,
16
+ credentialOverview,
17
+ type CredentialAdminContext,
18
+ } from "../mesh/credentials/index.js";
11
19
  import { log, logError } from "../../util/log.js";
20
+ import { checkGatewayTransport, hasValidGatewayToken } from "./gateway-auth.js";
12
21
 
13
22
  /** What the routes need from the Gateway that owns them. */
14
23
  export type GatewayRouteHost = {
15
- /** The /health body — identity fields plus live counters. */
16
- healthSnapshot: () => Record<string, unknown>;
24
+ /** The port the gateway is bound to — the only Host port it answers on. */
25
+ port: () => number;
26
+ /** The token every non-public route requires. */
27
+ token: () => string;
28
+ /**
29
+ * The /health body. `full` (an authenticated caller) adds the live
30
+ * counters; without it only the identity fields discovery matches on.
31
+ */
32
+ healthSnapshot: (full: boolean) => Record<string, unknown>;
17
33
  /** Schedule a graceful stop; false when this process cannot be stopped this way. */
18
34
  requestShutdown: () => boolean;
19
35
  /** Hot-reload plugins from config; resolves to the loaded plugin names. */
@@ -29,6 +45,8 @@ type RouteContext = {
29
45
  res: ServerResponse;
30
46
  url: URL;
31
47
  host: GatewayRouteHost;
48
+ /** True when the request carried a valid gateway token. */
49
+ authenticated: boolean;
32
50
  };
33
51
 
34
52
  type GatewayRoute = {
@@ -36,6 +54,8 @@ type GatewayRoute = {
36
54
  path: string;
37
55
  /** `prefix` matches `path` as a leading segment; default is an exact match. */
38
56
  match?: "prefix";
57
+ /** Served without the gateway token (still behind the transport guard). */
58
+ public?: boolean;
39
59
  handle: (ctx: RouteContext) => void | Promise<void>;
40
60
  };
41
61
 
@@ -55,9 +75,14 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
55
75
 
56
76
  const ROUTES: readonly GatewayRoute[] = [
57
77
  {
78
+ // Unauthenticated so discovery, container healthchecks and the MCP
79
+ // launcher's watchdog can probe it — which is why an anonymous caller
80
+ // only gets the identity fields, never the live counters.
58
81
  method: "GET",
59
82
  path: "/health",
60
- handle: ({ res, host }) => sendJson(res, 200, host.healthSnapshot()),
83
+ public: true,
84
+ handle: ({ res, host, authenticated }) =>
85
+ sendJson(res, 200, host.healthSnapshot(authenticated)),
61
86
  },
62
87
  {
63
88
  // Graceful stop for `talon stop`/`talon restart`. Respond before
@@ -145,6 +170,40 @@ const ROUTES: readonly GatewayRoute[] = [
145
170
  }
146
171
  },
147
172
  },
173
+ {
174
+ // Per-device mesh credentials — the transport for `talon mesh`.
175
+ method: "GET",
176
+ path: "/mesh/credentials",
177
+ handle: async ({ req, res }) => {
178
+ const ctx = await meshCredentialContext(req, res);
179
+ if (ctx) sendJson(res, 200, await credentialOverview(ctx));
180
+ },
181
+ },
182
+ {
183
+ // `talon mesh revoke|rotate|scopes <device>`. Revocation drops the
184
+ // device's live bridge sessions before this answers.
185
+ method: "POST",
186
+ path: "/mesh/credentials",
187
+ handle: async ({ req, res }) => {
188
+ const ctx = await meshCredentialContext(req, res);
189
+ if (!ctx) return;
190
+ // A JSON content type forces a CORS preflight this gateway never
191
+ // grants, so a web page cannot drive credential changes via the
192
+ // user's browser (a text/plain "simple" POST would skip it).
193
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
194
+ sendJson(res, 415, { ok: false, error: "Expected application/json" });
195
+ return;
196
+ }
197
+ let body: Record<string, unknown>;
198
+ try {
199
+ body = (await readJsonBody(req)) as Record<string, unknown>;
200
+ } catch {
201
+ sendJson(res, 400, { ok: false, error: "Invalid JSON" });
202
+ return;
203
+ }
204
+ sendJson(res, 200, await credentialAdmin(ctx, body));
205
+ },
206
+ },
148
207
  {
149
208
  // MCP hub — daemon-hosted MCP-over-HTTP endpoints for every backend
150
209
  // (see core/mcp-hub).
@@ -170,6 +229,31 @@ const ROUTES: readonly GatewayRoute[] = [
170
229
  },
171
230
  ];
172
231
 
232
+ /**
233
+ * The mesh's credential admin context, or null after answering why not.
234
+ * Browser-originated requests are refused outright: only local processes
235
+ * (the CLI) manage credentials.
236
+ */
237
+ async function meshCredentialContext(
238
+ req: IncomingMessage,
239
+ res: ServerResponse,
240
+ ): Promise<CredentialAdminContext | null> {
241
+ if (req.headers.origin) {
242
+ sendJson(res, 403, { ok: false, error: "Browser requests are refused" });
243
+ return null;
244
+ }
245
+ const mesh = getMeshService();
246
+ await mesh.load();
247
+ const ctx = mesh.credentialAdminContext();
248
+ if (!ctx) {
249
+ sendJson(res, 501, {
250
+ ok: false,
251
+ error: "This daemon has no per-device credential store",
252
+ });
253
+ }
254
+ return ctx;
255
+ }
256
+
173
257
  function matches(route: GatewayRoute, req: IncomingMessage): boolean {
174
258
  if (route.method !== "ANY" && req.method !== route.method) return false;
175
259
  const url = req.url ?? "";
@@ -184,7 +268,17 @@ export async function dispatchGatewayRoute(
184
268
  res: ServerResponse,
185
269
  host: GatewayRouteHost,
186
270
  ): Promise<void> {
271
+ const refusal = checkGatewayTransport(req, host.port());
272
+ if (refusal) {
273
+ sendJson(res, refusal.status, { ok: false, error: refusal.error });
274
+ return;
275
+ }
276
+ const authenticated = hasValidGatewayToken(req, host.token());
187
277
  const route = ROUTES.find((candidate) => matches(candidate, req));
278
+ if (!authenticated && !route?.public) {
279
+ sendJson(res, 401, { ok: false, error: "Unauthorized" });
280
+ return;
281
+ }
188
282
  if (!route) {
189
283
  res.writeHead(404);
190
284
  res.end("Not found");
@@ -196,6 +290,7 @@ export async function dispatchGatewayRoute(
196
290
  res,
197
291
  url: new URL(req.url ?? "/", "http://gateway"),
198
292
  host,
293
+ authenticated,
199
294
  });
200
295
  } catch (err) {
201
296
  if (res.headersSent) return;
@@ -30,6 +30,7 @@ import {
30
30
  listenWithRetry,
31
31
  type GatewayRouteHost,
32
32
  } from "./gateway-routes.js";
33
+ import { gatewayToken } from "./gateway-auth.js";
33
34
  import { handlePluginAction } from "../plugin/index.js";
34
35
  import type { FrontendActionHandler } from "../types.js";
35
36
  import type { Backend } from "../agent-runtime/capabilities.js";
@@ -422,8 +423,11 @@ export class Gateway {
422
423
 
423
424
  /** The actual bind. Always called through `start()`'s single-flight guard. */
424
425
  private async bind(port: number): Promise<number> {
426
+ const token = gatewayToken();
425
427
  const host: GatewayRouteHost = {
426
- healthSnapshot: () => this.healthSnapshot(),
428
+ port: () => this.port,
429
+ token: () => token,
430
+ healthSnapshot: (full) => this.healthSnapshot(full),
427
431
  requestShutdown: () => {
428
432
  const handler = this.shutdownHandler;
429
433
  if (!handler) return false;
@@ -458,17 +462,21 @@ export class Gateway {
458
462
  /**
459
463
  * The /health body. Identity fields first — daemon discovery matches on
460
464
  * them to tell a Talon daemon from chat sessions and unrelated localhost
461
- * services — then live counters.
465
+ * services — then, for an authenticated caller only, live counters.
462
466
  */
463
- private healthSnapshot(): Record<string, unknown> {
467
+ private healthSnapshot(full: boolean): Record<string, unknown> {
464
468
  const w = getHealthStatus();
465
- return {
469
+ const identity = {
466
470
  app: "talon",
467
471
  mode: this.mode,
468
472
  pid: process.pid,
469
473
  port: this.port,
470
474
  startedAt: this.startedAt,
471
475
  ok: w.healthy,
476
+ };
477
+ if (!full) return identity;
478
+ return {
479
+ ...identity,
472
480
  uptime: Math.round(process.uptime()),
473
481
  memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
474
482
  bridge: {