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.
- package/LICENSE +202 -21
- package/LICENSE-MIT +21 -0
- package/NOTICE +16 -0
- package/README.md +14 -7
- package/package.json +6 -4
- package/prompts/system/heartbeat-agent.md +1 -1
- package/src/backend/agy/factory.ts +3 -0
- package/src/backend/agy/mcp/config.ts +14 -2
- package/src/backend/claude-sdk/factory.ts +3 -0
- package/src/backend/claude-sdk/options.ts +24 -3
- package/src/backend/codex/factory.ts +3 -0
- package/src/backend/codex/init.ts +4 -0
- package/src/backend/codex/mcp-config.ts +10 -0
- package/src/backend/codex/oauth-incompat.ts +1 -1
- package/src/backend/codex/token-usage.ts +2 -2
- package/src/backend/openai-agents/factory.ts +3 -0
- package/src/backend/openai-agents/mcp-pool.ts +4 -0
- package/src/backend/remote-server/factory.ts +3 -0
- package/src/backend/remote-server/mcp.ts +3 -0
- package/src/backend/runtime/prompt/prompt-format.ts +3 -3
- package/src/bootstrap.ts +8 -0
- package/src/cli/commands/backup.ts +61 -5
- package/src/cli/commands/mesh.ts +133 -0
- package/src/cli/config.ts +3 -1
- package/src/cli/daemon-api.ts +22 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/install-sources.ts +40 -5
- package/src/cli/plugin.ts +10 -0
- package/src/cli/setup.ts +45 -4
- package/src/cli/skill.ts +3 -0
- package/src/core/agent-runtime/backend-registry.ts +16 -0
- package/src/core/backup/archive/crypt.ts +429 -0
- package/src/core/backup/archive/manifest-auth.ts +98 -0
- package/src/core/backup/passphrase.ts +130 -0
- package/src/core/backup/plan.ts +66 -13
- package/src/core/backup/restore-guard.ts +101 -0
- package/src/core/backup/restore.ts +249 -32
- package/src/core/backup/snapshot.ts +418 -60
- package/src/core/backup/sources/plugins.ts +223 -0
- package/src/core/backup/sources/relocate.ts +136 -0
- package/src/core/backup/sources/sessions.ts +198 -0
- package/src/core/backup/store.ts +3 -1
- package/src/core/backup/types.ts +66 -0
- package/src/core/backup/upload.ts +66 -6
- package/src/core/config/index.ts +140 -8
- package/src/core/daemon/control.ts +9 -0
- package/src/core/daemon/discovery.ts +7 -0
- package/src/core/engine/backend-router/headroom.ts +29 -5
- package/src/core/engine/backend-router/usage.ts +6 -2
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
- package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
- package/src/core/engine/gateway-actions/index.ts +4 -2
- package/src/core/engine/gateway-actions/native/index.ts +24 -0
- package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
- package/src/core/engine/gateway-auth.ts +164 -0
- package/src/core/engine/gateway-routes.ts +100 -5
- package/src/core/engine/gateway.ts +12 -4
- package/src/core/mcp-hub/guest-scope.ts +170 -29
- package/src/core/mcp-hub/index.ts +33 -16
- package/src/core/mcp-hub/talon-server.ts +71 -14
- package/src/core/mesh/credentials/admin.ts +146 -0
- package/src/core/mesh/credentials/index.ts +19 -0
- package/src/core/mesh/credentials/store.ts +443 -0
- package/src/core/mesh/credentials/token.ts +45 -0
- package/src/core/mesh/credentials/types.ts +83 -0
- package/src/core/mesh/devices/service.ts +58 -6
- package/src/core/mesh/links/bridge-links.ts +46 -4
- package/src/core/mesh/links/node-binaries.ts +1 -1
- package/src/core/mesh/links/node-provision.ts +8 -1
- package/src/core/models/active-model.ts +1 -1
- package/src/core/plugin/loader.ts +4 -0
- package/src/core/plugin/mcp.ts +4 -0
- package/src/core/tools/bridge.ts +2 -1
- package/src/core/types.ts +13 -0
- package/src/core/weaver/weaver.ts +47 -15
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
- package/src/frontend/discord/handlers/delivery.ts +3 -0
- package/src/frontend/discord/handlers/queue.ts +1 -0
- package/src/frontend/native/bridge/auth-guard.ts +277 -0
- package/src/frontend/native/bridge/auth.ts +84 -1
- package/src/frontend/native/bridge/credentials/claims.ts +51 -0
- package/src/frontend/native/bridge/credentials/principal.ts +200 -0
- package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
- package/src/frontend/native/bridge/routes/auth.ts +37 -0
- package/src/frontend/native/bridge/routes/chats.ts +20 -2
- package/src/frontend/native/bridge/routes/host.ts +11 -1
- package/src/frontend/native/bridge/routes/index.ts +2 -0
- package/src/frontend/native/bridge/routes/mesh.ts +72 -15
- package/src/frontend/native/bridge/routes/table.ts +73 -51
- package/src/frontend/native/bridge/server.ts +266 -83
- package/src/frontend/native/index.ts +54 -3
- package/src/frontend/native/turn/turn.ts +2 -0
- package/src/frontend/teams/turn.ts +1 -0
- package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
- package/src/frontend/telegram/actions/send.ts +4 -0
- package/src/frontend/telegram/admin.ts +20 -0
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/commands/state.ts +7 -9
- package/src/frontend/telegram/handlers/access.ts +31 -10
- package/src/frontend/telegram/handlers/delivery.ts +14 -1
- package/src/frontend/telegram/handlers/group-access.ts +50 -0
- package/src/frontend/telegram/handlers/messages.ts +1 -0
- package/src/frontend/telegram/handlers/queue.ts +14 -0
- package/src/frontend/telegram/handlers/state.ts +2 -2
- package/src/frontend/telegram/index.ts +32 -9
- package/src/frontend/telegram/middleware.ts +2 -2
- package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
- package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
- package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
- package/src/frontend/telegram/userbot.ts +103 -10
- package/src/frontend/terminal/index.ts +8 -1
- package/src/frontend/whatsapp/commands.ts +3 -3
- package/src/frontend/whatsapp/messages/inbound.ts +1 -0
- package/src/plugins/playwright/index.ts +1 -1
- package/src/storage/backup/index.ts +1 -1
- package/src/storage/db.ts +23 -0
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* `failed` with its reason and the run carries on — the next run retries
|
|
13
13
|
* it. The manifest keeps the per-target state alongside the parts, so a
|
|
14
14
|
* snapshot restored onto a new machine still knows where its copies are.
|
|
15
|
+
*
|
|
16
|
+
* Plaintext never leaves the box: before any target is contacted, every
|
|
17
|
+
* part's own bytes (not the manifest's say-so) must carry the encryption
|
|
18
|
+
* header. A snapshot taken without `backup.encryption` stays local, and
|
|
19
|
+
* each target records why. Parts marked `localOnly` (login sessions, by
|
|
20
|
+
* default) are never offered to a target at all; the manifest still lists
|
|
21
|
+
* them, so a restore from the remote copy knows what it is missing.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
import { bus } from "../bus/index.js";
|
|
@@ -21,6 +28,7 @@ import {
|
|
|
21
28
|
deleteBackupRemote,
|
|
22
29
|
recordBackupRemote,
|
|
23
30
|
} from "../../storage/backup/index.js";
|
|
31
|
+
import { isEncryptedFile } from "./archive/crypt.js";
|
|
24
32
|
import {
|
|
25
33
|
partPath,
|
|
26
34
|
reindexSnapshot,
|
|
@@ -28,7 +36,7 @@ import {
|
|
|
28
36
|
writeManifest,
|
|
29
37
|
} from "./store.js";
|
|
30
38
|
import type { BackupTarget } from "./targets.js";
|
|
31
|
-
import type { Manifest, RemoteState } from "./types.js";
|
|
39
|
+
import type { Manifest, RemoteState, SnapshotPart } from "./types.js";
|
|
32
40
|
|
|
33
41
|
function recordState(id: string, targetId: string, state: RemoteState): void {
|
|
34
42
|
recordBackupRemote({
|
|
@@ -41,14 +49,20 @@ function recordState(id: string, targetId: string, state: RemoteState): void {
|
|
|
41
49
|
});
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/** The parts a remote target may receive: everything not marked local-only. */
|
|
53
|
+
function remoteParts(manifest: Manifest): SnapshotPart[] {
|
|
54
|
+
return manifest.parts.filter((part) => part.localOnly !== true);
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
/** Push every part, then the manifest. Throws with the target's own words. */
|
|
45
58
|
async function sendSnapshot(
|
|
46
59
|
target: BackupTarget,
|
|
47
60
|
manifest: Manifest,
|
|
48
61
|
home: string,
|
|
49
62
|
): Promise<{ state: RemoteState; deduplicated: boolean }> {
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
const parts = remoteParts(manifest);
|
|
64
|
+
let deduplicated = parts.length > 0;
|
|
65
|
+
for (const part of parts) {
|
|
52
66
|
const result = await target.upload(
|
|
53
67
|
manifest.id,
|
|
54
68
|
{ ...part, path: partPath(manifest.id, part.name, home) },
|
|
@@ -63,6 +77,39 @@ async function sendSnapshot(
|
|
|
63
77
|
};
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
/** The refusal every target records for an unencrypted snapshot. */
|
|
81
|
+
export const PLAINTEXT_REFUSAL =
|
|
82
|
+
"remote backup targets require backup.encryption";
|
|
83
|
+
|
|
84
|
+
/** The first part whose file is not encrypted (or is missing), if any. */
|
|
85
|
+
async function firstPlaintextPart(
|
|
86
|
+
manifest: Manifest,
|
|
87
|
+
home: string,
|
|
88
|
+
): Promise<string | undefined> {
|
|
89
|
+
for (const part of remoteParts(manifest)) {
|
|
90
|
+
const encrypted = await isEncryptedFile(
|
|
91
|
+
partPath(manifest.id, part.name, home),
|
|
92
|
+
).catch(() => false);
|
|
93
|
+
if (!encrypted) return part.name;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Mark every target failed without contacting any of them. */
|
|
99
|
+
function refuseAll(
|
|
100
|
+
manifest: Manifest,
|
|
101
|
+
targets: readonly BackupTarget[],
|
|
102
|
+
partName: string,
|
|
103
|
+
): void {
|
|
104
|
+
const error = `${PLAINTEXT_REFUSAL} (part ${partName} is not encrypted; the snapshot stays local)`;
|
|
105
|
+
for (const target of targets) {
|
|
106
|
+
const state: RemoteState = { status: "failed", error };
|
|
107
|
+
manifest.remote[target.id] = state;
|
|
108
|
+
recordState(manifest.id, target.id, state);
|
|
109
|
+
}
|
|
110
|
+
logWarn("backup", `Not uploading ${manifest.id}: ${error}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
66
113
|
/**
|
|
67
114
|
* Upload one snapshot to every target. Returns the manifest with its
|
|
68
115
|
* `remote` map filled in; it is rewritten on disk and reindexed so the
|
|
@@ -74,6 +121,22 @@ export async function uploadSnapshot(
|
|
|
74
121
|
home: string = dirs.root,
|
|
75
122
|
): Promise<Manifest> {
|
|
76
123
|
if (targets.length === 0) return manifest;
|
|
124
|
+
const plaintext = await firstPlaintextPart(manifest, home);
|
|
125
|
+
if (plaintext !== undefined) {
|
|
126
|
+
refuseAll(manifest, targets, plaintext);
|
|
127
|
+
} else {
|
|
128
|
+
await uploadToAll(manifest, targets, home);
|
|
129
|
+
}
|
|
130
|
+
await writeManifest(manifest, home);
|
|
131
|
+
reindexSnapshot(manifest);
|
|
132
|
+
return manifest;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function uploadToAll(
|
|
136
|
+
manifest: Manifest,
|
|
137
|
+
targets: readonly BackupTarget[],
|
|
138
|
+
home: string,
|
|
139
|
+
): Promise<void> {
|
|
77
140
|
await Promise.all(
|
|
78
141
|
targets.map(async (target) => {
|
|
79
142
|
if (!target.ready) {
|
|
@@ -116,9 +179,6 @@ export async function uploadSnapshot(
|
|
|
116
179
|
}
|
|
117
180
|
}),
|
|
118
181
|
);
|
|
119
|
-
await writeManifest(manifest, home);
|
|
120
|
-
reindexSnapshot(manifest);
|
|
121
|
-
return manifest;
|
|
122
182
|
}
|
|
123
183
|
|
|
124
184
|
/**
|
package/src/core/config/index.ts
CHANGED
|
@@ -175,6 +175,25 @@ const nativeConfigSchema = z
|
|
|
175
175
|
* (~/.talon/keys/bridge-token) — the network never gets an open bridge.
|
|
176
176
|
*/
|
|
177
177
|
token: z.string().optional(),
|
|
178
|
+
/**
|
|
179
|
+
* Start the bridge on a network-reachable `host` even though `token`
|
|
180
|
+
* looks weak (under ~128 bits by a length × alphabet estimate, e.g.
|
|
181
|
+
* `hunter2`). Off by default: a weak token on a non-loopback bind
|
|
182
|
+
* refuses to start, with instructions to generate a strong one. With
|
|
183
|
+
* this set the bridge starts and logs a security warning every time.
|
|
184
|
+
* Loopback binds only ever warn.
|
|
185
|
+
*/
|
|
186
|
+
allowWeakToken: z.boolean().optional(),
|
|
187
|
+
/**
|
|
188
|
+
* Maximum lifetime of one authenticated event stream (`GET /events`),
|
|
189
|
+
* in ms. When set, each stream is closed after roughly this long
|
|
190
|
+
* (±10% jitter) and the client reconnects, presenting its token again.
|
|
191
|
+
* Unset (default) = streams live until the client leaves. The companion
|
|
192
|
+
* and talon-node both reconnect on their own, but with their own
|
|
193
|
+
* backoff, and a device command sent in that gap is dropped, so this is
|
|
194
|
+
* opt-in. Minimum 60000.
|
|
195
|
+
*/
|
|
196
|
+
sseMaxLifetimeMs: z.number().int().min(60_000).optional(),
|
|
178
197
|
/**
|
|
179
198
|
* Origins allowed to call the bridge from a BROWSER. Native clients
|
|
180
199
|
* (Electron main, Flutter, curl, talon-node) send no Origin header and
|
|
@@ -202,6 +221,29 @@ const nativeConfigSchema = z
|
|
|
202
221
|
.string()
|
|
203
222
|
.regex(/^https?:\/\/\S+$/, "native.publicUrl must be an http(s) URL")
|
|
204
223
|
.optional(),
|
|
224
|
+
/**
|
|
225
|
+
* Accept the shared `token` from remote (non-loopback, or proxied)
|
|
226
|
+
* clients. Every device now gets its own credential when it pairs, and
|
|
227
|
+
* devices still holding the shared token trade it for one in-band on
|
|
228
|
+
* their next connect; the daemon log and `talon mesh` list the ones
|
|
229
|
+
* that have not. Once none remain, set this to false and rotate
|
|
230
|
+
* `token` — it then only works for same-machine clients (the desktop
|
|
231
|
+
* app and CLI, via the 0600 discovery file). Default true for this
|
|
232
|
+
* release; the default flips to false in a later one.
|
|
233
|
+
*/
|
|
234
|
+
legacySharedToken: z.boolean().optional(),
|
|
235
|
+
/**
|
|
236
|
+
* Scopes a companion's per-device credential carries — at pairing and
|
|
237
|
+
* on the in-band upgrade. Default ["device", "client"]: the mesh plus
|
|
238
|
+
* the chat UI. Adding "operator" lets every paired phone change config,
|
|
239
|
+
* toggle plugins and read logs; prefer granting it to one device with
|
|
240
|
+
* `talon mesh scopes <device> device,client,operator`. Nodes always get
|
|
241
|
+
* ["device"].
|
|
242
|
+
*/
|
|
243
|
+
companionScopes: z
|
|
244
|
+
.array(z.enum(["device", "client", "operator"]))
|
|
245
|
+
.min(1)
|
|
246
|
+
.optional(),
|
|
205
247
|
})
|
|
206
248
|
.strict();
|
|
207
249
|
|
|
@@ -407,6 +449,19 @@ const configSchema = z.object({
|
|
|
407
449
|
apiHash: z.string().optional(),
|
|
408
450
|
adminUserId: z.number().int().optional(),
|
|
409
451
|
allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
|
|
452
|
+
/**
|
|
453
|
+
* Telegram groups the bot serves. When unset, groups are admitted by the
|
|
454
|
+
* admin's membership (legacy, warned at startup). Only the operator's own
|
|
455
|
+
* messages get the full tool set in any group.
|
|
456
|
+
*/
|
|
457
|
+
allowedGroups: z.array(z.number().int()).optional(),
|
|
458
|
+
/**
|
|
459
|
+
* Further operator identities, beyond `adminUserId`: messages from these
|
|
460
|
+
* senders get the full tool set; everyone else is guest-scoped. Forms:
|
|
461
|
+
* Telegram user id ("123"), WhatsApp "wa_dm_<number>", "discord:<userId>",
|
|
462
|
+
* "teams:<userId>". Discord `adminUserIds` are included automatically.
|
|
463
|
+
*/
|
|
464
|
+
operatorIds: z.array(z.string()).optional(),
|
|
410
465
|
// Denylist of user IDs dropped in silence — no warning reply, no admin
|
|
411
466
|
// notification. For spam and prompt-injection senders, where the warning
|
|
412
467
|
// itself is the reward: it confirms a live bot is reading.
|
|
@@ -539,12 +594,16 @@ const configSchema = z.object({
|
|
|
539
594
|
*
|
|
540
595
|
* - `workspaceInclude` — the workspace is mostly bulk that can be
|
|
541
596
|
* refetched; this is the subset that IS the agent.
|
|
597
|
+
* - `includeSessions` — backend session transcripts (Claude Code
|
|
598
|
+
* projects for the workspace, Codex/OpenCode/Kilo/Antigravity
|
|
599
|
+
* stores for enabled backends) and data/traces, in their own part.
|
|
542
600
|
* - `extraPaths` — absolute or `~/…` paths outside ~/.talon worth
|
|
543
601
|
* carrying along (a Claude Code memory directory, say).
|
|
544
602
|
* - `checkpointBeforeUpdate` — pinned checkpoint before `/update`,
|
|
545
603
|
* so a bad update is one restore away from undone.
|
|
546
604
|
* - `notifyChatId` — where failures are reported; falls back to the
|
|
547
605
|
* admin chat.
|
|
606
|
+
* - `encryption` / `loginSessions` — see docs/backup-security.md.
|
|
548
607
|
*/
|
|
549
608
|
backup: z
|
|
550
609
|
.object({
|
|
@@ -568,6 +627,17 @@ const configSchema = z.object({
|
|
|
568
627
|
.max(1000)
|
|
569
628
|
.default(DEFAULT_BACKUP_SETTINGS.keepRemote),
|
|
570
629
|
includePalace: z.boolean().default(DEFAULT_BACKUP_SETTINGS.includePalace),
|
|
630
|
+
/**
|
|
631
|
+
* WhatsApp auth + the userbot's Telegram login. "local" (default)
|
|
632
|
+
* keeps them in local snapshots only; "remote" also uploads them
|
|
633
|
+
* (encrypted); "off" leaves them out entirely.
|
|
634
|
+
*/
|
|
635
|
+
loginSessions: z
|
|
636
|
+
.enum(["off", "local", "remote"])
|
|
637
|
+
.default(DEFAULT_BACKUP_SETTINGS.loginSessions),
|
|
638
|
+
includeSessions: z
|
|
639
|
+
.boolean()
|
|
640
|
+
.default(DEFAULT_BACKUP_SETTINGS.includeSessions),
|
|
571
641
|
workspaceInclude: z
|
|
572
642
|
.array(z.string().min(1))
|
|
573
643
|
.default([...DEFAULT_BACKUP_SETTINGS.workspaceInclude]),
|
|
@@ -578,10 +648,31 @@ const configSchema = z.object({
|
|
|
578
648
|
.boolean()
|
|
579
649
|
.default(DEFAULT_BACKUP_SETTINGS.checkpointBeforeUpdate),
|
|
580
650
|
notifyChatId: z.string().optional(),
|
|
651
|
+
/**
|
|
652
|
+
* Encrypt every part (AES-256-GCM, scrypt-derived key). The
|
|
653
|
+
* passphrase comes from TALON_BACKUP_PASSPHRASE or this file —
|
|
654
|
+
* never inline, since config.json is itself inside the backup.
|
|
655
|
+
* Without a passphrase, remote targets refuse the upload.
|
|
656
|
+
*/
|
|
657
|
+
encryption: z
|
|
658
|
+
.object({ passphraseFile: z.string().trim().min(1).optional() })
|
|
659
|
+
.strict()
|
|
660
|
+
.optional(),
|
|
581
661
|
})
|
|
582
662
|
.strict()
|
|
583
663
|
.optional(),
|
|
584
664
|
braveApiKey: z.string().optional(),
|
|
665
|
+
/**
|
|
666
|
+
* `fetch_url` refuses hosts that resolve to loopback, private (RFC 1918,
|
|
667
|
+
* CGNAT, ULA), link-local (incl. the 169.254.169.254 metadata endpoint)
|
|
668
|
+
* or reserved addresses, re-checking every redirect hop. Set
|
|
669
|
+
* `allowPrivateNetworks: true` only on a host where the agent should
|
|
670
|
+
* read local services (a home lab, a dev server).
|
|
671
|
+
*/
|
|
672
|
+
fetchUrl: z
|
|
673
|
+
.object({ allowPrivateNetworks: z.boolean().default(false) })
|
|
674
|
+
.strict()
|
|
675
|
+
.optional(),
|
|
585
676
|
/**
|
|
586
677
|
* Codex-specific OpenAI API key. Prefer this, CODEX_API_KEY, or
|
|
587
678
|
* TALON_CODEX_KEY when the Codex backend should use API-key billing
|
|
@@ -641,18 +732,24 @@ const configSchema = z.object({
|
|
|
641
732
|
disabledToolTags: z.array(z.string()).optional(),
|
|
642
733
|
|
|
643
734
|
/**
|
|
644
|
-
* Conversation-only tool surface for
|
|
645
|
-
* operator
|
|
646
|
-
* everything; other DMs (Telegram user ids, `wa_dm_*`) get reply/react/
|
|
735
|
+
* Conversation-only ("guest") tool surface for anyone who isn't an
|
|
736
|
+
* operator (`adminUserId`, `operatorIds`, `operatorChats`): reply/react/
|
|
647
737
|
* history/stickers plus the `guestPlugins` servers — no shell, files,
|
|
648
|
-
* mail, devices, cron, memory, agents or cross-chat sends.
|
|
649
|
-
*
|
|
738
|
+
* mail, devices, cron, memory, agents or cross-chat sends. Always applied
|
|
739
|
+
* to non-operator senders in groups; applied to non-operator DMs unless
|
|
740
|
+
* `enabled: false` (legacy opt-out). See core/mcp-hub/guest-scope.ts.
|
|
650
741
|
*/
|
|
651
742
|
guestDmScope: z
|
|
652
743
|
.object({
|
|
653
|
-
enabled: z.boolean().default(
|
|
744
|
+
enabled: z.boolean().default(true),
|
|
654
745
|
operatorChats: z.array(z.string()).default([]),
|
|
655
746
|
guestPlugins: z.array(z.string()).optional(),
|
|
747
|
+
/**
|
|
748
|
+
* Groups the operator is a member of give EVERY member the full
|
|
749
|
+
* surface (shell, files, mail, devices…). Off by default; only for
|
|
750
|
+
* groups whose members the operator trusts with the host.
|
|
751
|
+
*/
|
|
752
|
+
operatorGroups: z.boolean().default(false),
|
|
656
753
|
})
|
|
657
754
|
.optional(),
|
|
658
755
|
|
|
@@ -1016,9 +1113,11 @@ export function loadConfig(): TalonConfig {
|
|
|
1016
1113
|
? parsed.frontend
|
|
1017
1114
|
: [parsed.frontend];
|
|
1018
1115
|
for (const fe of frontends) {
|
|
1019
|
-
if (fe === "telegram" && !parsed.botToken) {
|
|
1116
|
+
if (fe === "telegram" && (!parsed.botToken || !parsed.adminUserId)) {
|
|
1020
1117
|
throw new Error(
|
|
1021
|
-
|
|
1118
|
+
parsed.botToken
|
|
1119
|
+
? TELEGRAM_ADMIN_REQUIRED
|
|
1120
|
+
: `Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
|
|
1022
1121
|
);
|
|
1023
1122
|
}
|
|
1024
1123
|
if (fe === "teams" && !parsed.teamsWebhookUrl) {
|
|
@@ -1045,6 +1144,39 @@ export function loadConfig(): TalonConfig {
|
|
|
1045
1144
|
};
|
|
1046
1145
|
}
|
|
1047
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Why a Telegram frontend without `adminUserId` refuses to start: the admin
|
|
1149
|
+
* is who may run operator commands and who the DM allowlist defaults to, so
|
|
1150
|
+
* without one the bot would have no owner at all.
|
|
1151
|
+
*/
|
|
1152
|
+
export const TELEGRAM_ADMIN_REQUIRED =
|
|
1153
|
+
`Telegram frontend requires "adminUserId" (your numeric Telegram user id) in ${CONFIG_FILE}. ` +
|
|
1154
|
+
`It decides who may run admin commands and, unless "allowedUsers" lists more people, who may DM the bot. ` +
|
|
1155
|
+
`Find your id by messaging @userinfobot, then run "talon setup" or add "adminUserId": <id> to the config.`;
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Only the `backup` block, validated — without writing a default config
|
|
1159
|
+
* or checking frontend requirements. `talon backup restore` on a fresh
|
|
1160
|
+
* host has no real config yet (it is inside the snapshot), and the
|
|
1161
|
+
* default one would fail on its empty botToken before anything restored.
|
|
1162
|
+
*/
|
|
1163
|
+
export function loadBackupConfig(): TalonConfig["backup"] {
|
|
1164
|
+
const fileConfig = loadConfigFile();
|
|
1165
|
+
const result = configSchema.shape.backup.safeParse(fileConfig.backup);
|
|
1166
|
+
if (!result.success) {
|
|
1167
|
+
const issues = formatSchemaIssues(result.error).map(
|
|
1168
|
+
(line) => `backup.${line}`,
|
|
1169
|
+
);
|
|
1170
|
+
throw new ConfigFileError(
|
|
1171
|
+
`Invalid backup config in ${CONFIG_FILE}:\n` +
|
|
1172
|
+
issues.map((line) => ` - ${line}`).join("\n"),
|
|
1173
|
+
CONFIG_FILE,
|
|
1174
|
+
issues,
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
return result.data;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1048
1180
|
/**
|
|
1049
1181
|
* Rebuild the system prompt with plugin additions.
|
|
1050
1182
|
* Called after plugins are loaded to inject their prompt contributions.
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
16
|
import { dirname, resolve } from "node:path";
|
|
17
17
|
import { isBunRuntime } from "../../util/runtime.js";
|
|
18
|
+
import {
|
|
19
|
+
gatewayAuthHeaders,
|
|
20
|
+
readGatewayToken,
|
|
21
|
+
} from "../engine/gateway-auth.js";
|
|
18
22
|
import {
|
|
19
23
|
readPidRecord,
|
|
20
24
|
writePidRecord,
|
|
@@ -157,6 +161,11 @@ async function requestShutdown(port: number): Promise<boolean> {
|
|
|
157
161
|
try {
|
|
158
162
|
const resp = await fetch(`http://127.0.0.1:${port}/shutdown`, {
|
|
159
163
|
method: "POST",
|
|
164
|
+
headers: {
|
|
165
|
+
"Content-Type": "application/json",
|
|
166
|
+
...gatewayAuthHeaders(readGatewayToken()),
|
|
167
|
+
},
|
|
168
|
+
body: "{}",
|
|
160
169
|
signal: AbortSignal.timeout(2000),
|
|
161
170
|
});
|
|
162
171
|
if (!resp.ok) return false;
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readPidRecord, isProcessAlive } from "./pidfile.js";
|
|
20
|
+
import {
|
|
21
|
+
gatewayAuthHeaders,
|
|
22
|
+
readGatewayToken,
|
|
23
|
+
} from "../engine/gateway-auth.js";
|
|
20
24
|
|
|
21
25
|
export type DaemonHealth = {
|
|
22
26
|
app?: string;
|
|
@@ -78,7 +82,10 @@ export async function probeHealth(
|
|
|
78
82
|
timeoutMs = 800,
|
|
79
83
|
): Promise<DaemonHealth | null> {
|
|
80
84
|
try {
|
|
85
|
+
// The token is optional here: identity fields answer without it, the
|
|
86
|
+
// live counters `talon status` shows need it.
|
|
81
87
|
const resp = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
88
|
+
headers: gatewayAuthHeaders(readGatewayToken()),
|
|
82
89
|
signal: AbortSignal.timeout(timeoutMs),
|
|
83
90
|
});
|
|
84
91
|
if (!resp.ok) return null;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import type { PlanUsage } from "../../agent-runtime/capabilities.js";
|
|
27
27
|
import type { TalonConfig } from "../../config/index.js";
|
|
28
28
|
import {
|
|
29
|
+
acquireBackendInstance,
|
|
29
30
|
getPooledBackend,
|
|
30
31
|
listAvailableBackends,
|
|
31
32
|
} from "../backend-controller/index.js";
|
|
@@ -191,12 +192,35 @@ function unknownHeadroom(
|
|
|
191
192
|
return { id, label, headroom: 1, source: "none", fetchedAt: now };
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Ask a backend for its plan windows. Rejects like the backend does.
|
|
197
|
+
*
|
|
198
|
+
* A pooled instance is used as-is. An idle backend (no chat bound to it) is
|
|
199
|
+
* booted transiently for the read and released again: its quota is just as
|
|
200
|
+
* real when nothing is routed to it, and reporting "not running" there made
|
|
201
|
+
* `/usage` hide exactly the headroom the router needs to pick a backend that
|
|
202
|
+
* is *not* currently in use. The read is the backend's own cached one, so at
|
|
203
|
+
* most one boot per cache window.
|
|
204
|
+
*/
|
|
195
205
|
async function readPlanUsage(id: string): Promise<PlanUsage | undefined> {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
const pooled = getPooledBackend(id);
|
|
207
|
+
if (pooled?.usage?.getPlanUsage) {
|
|
208
|
+
return pooled.usage.getPlanUsage.call(pooled.usage);
|
|
209
|
+
}
|
|
210
|
+
if (pooled) return undefined; // pooled, but reports no plan windows
|
|
211
|
+
let acquired;
|
|
212
|
+
try {
|
|
213
|
+
acquired = await acquireBackendInstance(id);
|
|
214
|
+
} catch {
|
|
215
|
+
return undefined; // can't boot it (not configured, no auth) — stay quiet
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const read = acquired.backend.usage?.getPlanUsage;
|
|
219
|
+
if (!read || !acquired.backend.usage) return undefined;
|
|
220
|
+
return await read.call(acquired.backend.usage);
|
|
221
|
+
} finally {
|
|
222
|
+
await acquired.release().catch(() => {});
|
|
223
|
+
}
|
|
200
224
|
}
|
|
201
225
|
|
|
202
226
|
/**
|
|
@@ -34,9 +34,13 @@ export interface BackendUsageSnapshot {
|
|
|
34
34
|
/** The reason a backend has no plan windows to show. */
|
|
35
35
|
function noteFor(id: string, headroom: BackendHeadroom): string {
|
|
36
36
|
const backend = getPooledBackend(id);
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// An idle backend is booted transiently for the plan read (see
|
|
38
|
+
// headroom.ts), so reaching here without windows means it really has none
|
|
39
|
+
// to report — not that it is "not running".
|
|
40
|
+
if (backend && !backend.usage?.getPlanUsage)
|
|
41
|
+
return "no plan limits on this backend";
|
|
39
42
|
if (headroom.source === "ledger") return "tracked against a local budget";
|
|
43
|
+
if (headroom.source === "none") return "no usage information available";
|
|
40
44
|
return "no usage information available";
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guard for `fetch_url`.
|
|
3
|
+
*
|
|
4
|
+
* The URL comes from the model, and the model reads untrusted pages, so
|
|
5
|
+
* "fetch this" can be steered at anything the daemon's host can reach:
|
|
6
|
+
* the cloud metadata endpoint (169.254.169.254 hands out instance
|
|
7
|
+
* credentials), the loopback gateway, a router admin page on the LAN.
|
|
8
|
+
* So before every request — the first one and each redirect hop — the
|
|
9
|
+
* host is resolved and EVERY address it resolves to must be public.
|
|
10
|
+
* Redirects are followed by hand (never by fetch) so a public page
|
|
11
|
+
* cannot bounce the request into a private one.
|
|
12
|
+
*
|
|
13
|
+
* Residual risk, stated plainly: fetch resolves the name again after we
|
|
14
|
+
* checked it, so a DNS-rebinding server with a zero TTL can still race
|
|
15
|
+
* us. Pinning the checked address would need a custom connector, which
|
|
16
|
+
* Bun (one of the two runtimes) does not honour. The guard closes the
|
|
17
|
+
* direct, redirect and static-DNS paths; rebinding needs a hostile DNS
|
|
18
|
+
* server and a lucky race.
|
|
19
|
+
*
|
|
20
|
+
* Operators who run Talon next to services they WANT it to read (a home
|
|
21
|
+
* lab, a local dev server) can opt out with
|
|
22
|
+
* `fetchUrl.allowPrivateNetworks: true`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { lookup } from "node:dns/promises";
|
|
26
|
+
import { isIP } from "node:net";
|
|
27
|
+
|
|
28
|
+
/** Resolves a host name to every address it maps to. Injected in tests. */
|
|
29
|
+
export type Resolver = (host: string) => Promise<string[]>;
|
|
30
|
+
|
|
31
|
+
const defaultResolver: Resolver = async (host) =>
|
|
32
|
+
(await lookup(host, { all: true, verbatim: true })).map((r) => r.address);
|
|
33
|
+
|
|
34
|
+
export class BlockedUrlError extends Error {}
|
|
35
|
+
|
|
36
|
+
const MAX_REDIRECTS = 5;
|
|
37
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
38
|
+
|
|
39
|
+
// ── Address classification ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Non-public IPv4 ranges: [network, prefix length]. */
|
|
42
|
+
const BLOCKED_V4: ReadonlyArray<[string, number]> = [
|
|
43
|
+
["0.0.0.0", 8], // "this network"
|
|
44
|
+
["10.0.0.0", 8], // RFC 1918
|
|
45
|
+
["100.64.0.0", 10], // CGNAT (also Tailscale)
|
|
46
|
+
["127.0.0.0", 8], // loopback
|
|
47
|
+
["169.254.0.0", 16], // link-local, cloud metadata
|
|
48
|
+
["172.16.0.0", 12], // RFC 1918
|
|
49
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
50
|
+
["192.0.2.0", 24], // TEST-NET-1
|
|
51
|
+
["192.88.99.0", 24], // 6to4 relay anycast
|
|
52
|
+
["192.168.0.0", 16], // RFC 1918
|
|
53
|
+
["198.18.0.0", 15], // benchmarking
|
|
54
|
+
["198.51.100.0", 24], // TEST-NET-2
|
|
55
|
+
["203.0.113.0", 24], // TEST-NET-3
|
|
56
|
+
["224.0.0.0", 4], // multicast
|
|
57
|
+
["240.0.0.0", 4], // reserved + broadcast
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Non-public IPv6 ranges (IPv4-mapped/compatible are unwrapped first). */
|
|
61
|
+
const BLOCKED_V6: ReadonlyArray<[string, number]> = [
|
|
62
|
+
["::", 128], // unspecified
|
|
63
|
+
["::1", 128], // loopback
|
|
64
|
+
["64:ff9b::", 96], // NAT64 — reaches whatever IPv4 it embeds
|
|
65
|
+
["64:ff9b:1::", 48], // local-use NAT64
|
|
66
|
+
["100::", 64], // discard
|
|
67
|
+
["2001::", 32], // Teredo
|
|
68
|
+
["2001:db8::", 32], // documentation
|
|
69
|
+
["2002::", 16], // 6to4 — embeds an IPv4 address
|
|
70
|
+
["fc00::", 7], // unique local
|
|
71
|
+
["fe80::", 10], // link-local
|
|
72
|
+
["fec0::", 10], // site-local (deprecated)
|
|
73
|
+
["ff00::", 8], // multicast
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function v4ToInt(ip: string): number {
|
|
77
|
+
return ip
|
|
78
|
+
.split(".")
|
|
79
|
+
.reduce((acc, octet) => ((acc << 8) | Number(octet)) >>> 0, 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function v6ToBigInt(ip: string): bigint {
|
|
83
|
+
let text = ip.toLowerCase().split("%")[0];
|
|
84
|
+
// A trailing dotted quad (::ffff:1.2.3.4) becomes two hextets.
|
|
85
|
+
const dotted = text.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
86
|
+
if (dotted) {
|
|
87
|
+
const n = v4ToInt(dotted[1]);
|
|
88
|
+
text =
|
|
89
|
+
text.slice(0, -dotted[1].length) +
|
|
90
|
+
`${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
|
|
91
|
+
}
|
|
92
|
+
const [head, tail] = text.includes("::") ? text.split("::") : [text, null];
|
|
93
|
+
const headParts = head ? head.split(":") : [];
|
|
94
|
+
const tailParts = tail ? tail.split(":") : [];
|
|
95
|
+
const fill = tail === null ? 0 : 8 - headParts.length - tailParts.length;
|
|
96
|
+
const parts = [...headParts, ...Array(fill).fill("0"), ...tailParts];
|
|
97
|
+
return parts.reduce(
|
|
98
|
+
(acc, part) => (acc << 16n) | BigInt(parseInt(part || "0", 16)),
|
|
99
|
+
0n,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inV4(ip: number, [net, bits]: [string, number]): boolean {
|
|
104
|
+
if (bits === 0) return true;
|
|
105
|
+
const mask = (~0 << (32 - bits)) >>> 0;
|
|
106
|
+
return (ip & mask) === (v4ToInt(net) & mask);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function inV6(ip: bigint, [net, bits]: [string, number]): boolean {
|
|
110
|
+
const shift = BigInt(128 - bits);
|
|
111
|
+
return ip >> shift === v6ToBigInt(net) >> shift;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when `ip` is loopback, private, link-local, reserved or otherwise non-public. */
|
|
115
|
+
export function isBlockedAddress(ip: string): boolean {
|
|
116
|
+
const family = isIP(ip.split("%")[0]);
|
|
117
|
+
if (family === 4) {
|
|
118
|
+
const n = v4ToInt(ip);
|
|
119
|
+
return BLOCKED_V4.some((range) => inV4(n, range));
|
|
120
|
+
}
|
|
121
|
+
if (family !== 6) return true; // not an address at all: refuse
|
|
122
|
+
const n = v6ToBigInt(ip);
|
|
123
|
+
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses
|
|
124
|
+
// reach the embedded IPv4 host — judge that instead.
|
|
125
|
+
const high = n >> 32n;
|
|
126
|
+
if (high === 0xffffn || (high === 0n && n > 1n)) {
|
|
127
|
+
const v4 = Number(n & 0xffffffffn);
|
|
128
|
+
return BLOCKED_V4.some((range) => inV4(v4, range));
|
|
129
|
+
}
|
|
130
|
+
return BLOCKED_V6.some((range) => inV6(n, range));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── URL checks ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Throw unless `url` is http(s) and its host resolves only to public
|
|
137
|
+
* addresses. A literal IP is judged directly; a name is resolved.
|
|
138
|
+
*/
|
|
139
|
+
export async function assertPublicUrl(
|
|
140
|
+
url: URL,
|
|
141
|
+
resolve: Resolver = defaultResolver,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
144
|
+
throw new BlockedUrlError("URL must use http or https protocol");
|
|
145
|
+
}
|
|
146
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
147
|
+
let addresses: string[];
|
|
148
|
+
if (isIP(host)) {
|
|
149
|
+
addresses = [host];
|
|
150
|
+
} else {
|
|
151
|
+
try {
|
|
152
|
+
addresses = await resolve(host);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new BlockedUrlError(
|
|
155
|
+
`Cannot resolve ${host}: ${err instanceof Error ? err.message : String(err)}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (addresses.length === 0) {
|
|
160
|
+
throw new BlockedUrlError(`Cannot resolve ${host}`);
|
|
161
|
+
}
|
|
162
|
+
const blocked = addresses.find(isBlockedAddress);
|
|
163
|
+
if (blocked) {
|
|
164
|
+
throw new BlockedUrlError(
|
|
165
|
+
`Refusing to fetch ${host}: it resolves to a private, loopback or link-local address (${blocked}). ` +
|
|
166
|
+
`Set fetchUrl.allowPrivateNetworks in config.json to allow local addresses.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type GuardedFetchOptions = {
|
|
172
|
+
allowPrivateNetworks?: boolean;
|
|
173
|
+
resolve?: Resolver;
|
|
174
|
+
maxRedirects?: number;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `fetch` with the guard applied to the first request and to every
|
|
179
|
+
* redirect hop. Returns the final (non-redirect) response.
|
|
180
|
+
*/
|
|
181
|
+
export async function guardedFetch(
|
|
182
|
+
input: string,
|
|
183
|
+
init: RequestInit,
|
|
184
|
+
options: GuardedFetchOptions = {},
|
|
185
|
+
): Promise<Response> {
|
|
186
|
+
const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS;
|
|
187
|
+
let url = new URL(input);
|
|
188
|
+
for (let hop = 0; ; hop++) {
|
|
189
|
+
if (!options.allowPrivateNetworks) {
|
|
190
|
+
await assertPublicUrl(url, options.resolve);
|
|
191
|
+
}
|
|
192
|
+
const resp = await fetch(url, { ...init, redirect: "manual" });
|
|
193
|
+
const location = resp.headers.get("location");
|
|
194
|
+
if (!REDIRECT_STATUSES.has(resp.status) || !location) return resp;
|
|
195
|
+
if (hop >= maxRedirects) {
|
|
196
|
+
throw new BlockedUrlError(`Too many redirects (max ${maxRedirects})`);
|
|
197
|
+
}
|
|
198
|
+
await resp.body?.cancel().catch(() => {});
|
|
199
|
+
url = new URL(location, url);
|
|
200
|
+
}
|
|
201
|
+
}
|