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
|
@@ -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] [
|
|
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. "
|
|
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. `
|
|
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 {
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/cli/daemon-api.ts
CHANGED
|
@@ -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)
|
|
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 }
|
|
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: "
|
|
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
|
-
|
|
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
|
}
|