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
|
@@ -24,6 +24,22 @@ import {
|
|
|
24
24
|
type NodeBinaryResolver,
|
|
25
25
|
} from "./node-binaries.js";
|
|
26
26
|
import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_COMPANION_SCOPES,
|
|
29
|
+
NODE_SCOPES,
|
|
30
|
+
type CredentialOrigin,
|
|
31
|
+
type MeshScope,
|
|
32
|
+
} from "../credentials/index.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mints the per-device credential a pairing link or installer carries,
|
|
36
|
+
* unbound until the device first names itself with it. Absent (tests,
|
|
37
|
+
* embedders without a credential store) = links carry the shared token.
|
|
38
|
+
*/
|
|
39
|
+
export type PairingCredentialMinter = (
|
|
40
|
+
scopes: readonly MeshScope[],
|
|
41
|
+
origin: CredentialOrigin,
|
|
42
|
+
) => string;
|
|
27
43
|
|
|
28
44
|
/**
|
|
29
45
|
* What the native bridge tells the mesh about itself once it's listening —
|
|
@@ -45,6 +61,10 @@ export type MeshBridgeInfo = {
|
|
|
45
61
|
* address isn't reachable as-is (containers, NAT, proxies).
|
|
46
62
|
*/
|
|
47
63
|
publicUrl?: string;
|
|
64
|
+
/** `native.legacySharedToken`: whether remote clients may still use `token`. */
|
|
65
|
+
legacySharedToken?: boolean;
|
|
66
|
+
/** Scopes a companion's pairing credential carries (`native.companionScopes`). */
|
|
67
|
+
companionScopes?: readonly MeshScope[];
|
|
48
68
|
};
|
|
49
69
|
|
|
50
70
|
export class BridgeLinks {
|
|
@@ -54,7 +74,24 @@ export class BridgeLinks {
|
|
|
54
74
|
private readonly companionPairs = new CompanionPairStore();
|
|
55
75
|
private bridgeInfo: MeshBridgeInfo | null = null;
|
|
56
76
|
|
|
57
|
-
constructor(
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly resolveNode: NodeBinaryResolver,
|
|
79
|
+
private readonly mintCredential?: PairingCredentialMinter,
|
|
80
|
+
) {}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The bearer a link hands a new device: its own credential when the mesh
|
|
84
|
+
* has a credential store, else the shared bridge token (legacy).
|
|
85
|
+
*/
|
|
86
|
+
private linkCredential(
|
|
87
|
+
sharedToken: string,
|
|
88
|
+
scopes: readonly MeshScope[],
|
|
89
|
+
origin: CredentialOrigin,
|
|
90
|
+
): string {
|
|
91
|
+
return this.mintCredential
|
|
92
|
+
? this.mintCredential(scopes, origin)
|
|
93
|
+
: sharedToken;
|
|
94
|
+
}
|
|
58
95
|
|
|
59
96
|
/** The native bridge reports its reachable identity here (null on stop). */
|
|
60
97
|
setBridgeInfo(info: MeshBridgeInfo | null): void {
|
|
@@ -133,7 +170,7 @@ export class BridgeLinks {
|
|
|
133
170
|
size: bin.size,
|
|
134
171
|
version: bin.version,
|
|
135
172
|
bridgeUrl: base,
|
|
136
|
-
bearerToken: info.token,
|
|
173
|
+
bearerToken: this.linkCredential(info.token, NODE_SCOPES, "install"),
|
|
137
174
|
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
138
175
|
});
|
|
139
176
|
return {
|
|
@@ -185,9 +222,14 @@ export class BridgeLinks {
|
|
|
185
222
|
}
|
|
186
223
|
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
187
224
|
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
225
|
+
const token = this.linkCredential(
|
|
226
|
+
info.token,
|
|
227
|
+
info.companionScopes ?? DEFAULT_COMPANION_SCOPES,
|
|
228
|
+
"pair",
|
|
229
|
+
);
|
|
188
230
|
const grant = this.companionPairs.create({
|
|
189
231
|
bridgeUrl: base,
|
|
190
|
-
bearerToken:
|
|
232
|
+
bearerToken: token,
|
|
191
233
|
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
192
234
|
...(typeof label === "string" && label.trim()
|
|
193
235
|
? { label: label.trim() }
|
|
@@ -197,7 +239,7 @@ export class BridgeLinks {
|
|
|
197
239
|
ok: true,
|
|
198
240
|
link: pairLink(grant),
|
|
199
241
|
url: base,
|
|
200
|
-
token
|
|
242
|
+
token,
|
|
201
243
|
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
202
244
|
};
|
|
203
245
|
}
|
|
@@ -57,7 +57,7 @@ export const NODE_TARGETS: readonly NodeTarget[] = [
|
|
|
57
57
|
];
|
|
58
58
|
|
|
59
59
|
const SUMS_ASSET = "talon-node-SHA256SUMS";
|
|
60
|
-
const RELEASE_BASE = "https://github.com/
|
|
60
|
+
const RELEASE_BASE = "https://github.com/thefalconry/talon/releases/download";
|
|
61
61
|
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
62
62
|
const BUILD_TIMEOUT_MS = 300_000;
|
|
63
63
|
|
|
@@ -166,7 +166,13 @@ echo "Done — this host is now on the mesh. Check with: $BIN status"
|
|
|
166
166
|
`;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* PowerShell installer (Windows PowerShell 5+ compatible). The binary lands in
|
|
171
|
+
* the installing user's %LOCALAPPDATA% and `talon-node install` registers a
|
|
172
|
+
* boot task that runs as that same user (never SYSTEM — a SYSTEM task would
|
|
173
|
+
* run a binary the user can rewrite). Registering a boot task needs an
|
|
174
|
+
* elevated PowerShell.
|
|
175
|
+
*/
|
|
170
176
|
function powershellInstaller(grant: NodeProvisionGrant): string {
|
|
171
177
|
const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
|
|
172
178
|
const fpArg = grant.fingerprint
|
|
@@ -184,6 +190,7 @@ Invoke-WebRequest -UseBasicParsing "$bridge/node/binary?provision=${grant.token}
|
|
|
184
190
|
$got = (Get-FileHash $bin -Algorithm SHA256).Hash.ToLower()
|
|
185
191
|
if ($got -ne "${grant.sha256}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
|
|
186
192
|
Write-Host "Installed $bin"
|
|
193
|
+
Write-Host "Registering the boot task to run as $env:USERDOMAIN\\$env:USERNAME (not SYSTEM)..."
|
|
187
194
|
& $bin install --bridge $bridge --token "${grant.bearerToken}"${fpArg}${nameArg}
|
|
188
195
|
Write-Host "Done - this host is now on the mesh. Check with: $bin status"
|
|
189
196
|
`;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* a single global-default fallback that ignored the per-chat backend.
|
|
15
15
|
* That produced two recurring bug classes:
|
|
16
16
|
*
|
|
17
|
-
* 1. **Reset-on-non-default-backend** (
|
|
17
|
+
* 1. **Reset-on-non-default-backend** (Ada, 2026-05-21): chat on
|
|
18
18
|
* Codex, user hits Reset, code clears the override, read side
|
|
19
19
|
* returns `config.model = "claude-opus-4-7"`. Codex chat then
|
|
20
20
|
* tries to run an Anthropic id.
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "./types.js";
|
|
16
16
|
import { isMcpPlugin } from "./types.js";
|
|
17
17
|
import { registry, _deps } from "./registry.js";
|
|
18
|
+
import { GATEWAY_TOKEN_ENV } from "../engine/gateway-auth.js";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Candidate entry point paths, checked in order. Exported for
|
|
@@ -67,6 +68,9 @@ export async function loadPlugins(
|
|
|
67
68
|
|
|
68
69
|
function applyEnvVars(envVars: Record<string, string>): void {
|
|
69
70
|
for (const [key, value] of Object.entries(envVars)) {
|
|
71
|
+
// The gateway token is the daemon's own credential; a plugin may read
|
|
72
|
+
// it but never replace it.
|
|
73
|
+
if (key === GATEWAY_TOKEN_ENV) continue;
|
|
70
74
|
process.env[key] = value;
|
|
71
75
|
}
|
|
72
76
|
}
|
package/src/core/plugin/mcp.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { wrapMcpServer } from "../mcp-hub/launcher.js";
|
|
|
10
10
|
import { isBunRuntime } from "../../util/runtime.js";
|
|
11
11
|
import { registry, reloadState } from "./registry.js";
|
|
12
12
|
import type { McpServerConfig } from "./types.js";
|
|
13
|
+
import { GATEWAY_TOKEN_ENV, gatewayToken } from "../engine/gateway-auth.js";
|
|
13
14
|
|
|
14
15
|
function buildBridgeEnv(
|
|
15
16
|
bridgeUrl: string,
|
|
@@ -19,6 +20,9 @@ function buildBridgeEnv(
|
|
|
19
20
|
return {
|
|
20
21
|
...envVars,
|
|
21
22
|
TALON_BRIDGE_URL: bridgeUrl,
|
|
23
|
+
// Plugins that call back into the gateway (POST /action) must send
|
|
24
|
+
// this as `Authorization: Bearer <token>`.
|
|
25
|
+
[GATEWAY_TOKEN_ENV]: gatewayToken(),
|
|
22
26
|
TALON_CHAT_ID: chatId,
|
|
23
27
|
TALON_RELOAD_AT: reloadState.lastReloadAt,
|
|
24
28
|
};
|
package/src/core/tools/bridge.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
14
14
|
import { isBunRuntime } from "../../util/runtime.js";
|
|
15
|
+
import { gatewayAuthHeaders } from "../engine/gateway-auth.js";
|
|
15
16
|
import type { BridgeFunction } from "./types.js";
|
|
16
17
|
|
|
17
18
|
/** Default wall-clock budget for a bridge action. */
|
|
@@ -91,7 +92,7 @@ export function createBridge(
|
|
|
91
92
|
const timeoutMs = LONG_ACTION_TIMEOUTS_MS[action] ?? DEFAULT_TIMEOUT_MS;
|
|
92
93
|
const init = {
|
|
93
94
|
method: "POST",
|
|
94
|
-
headers: { "Content-Type": "application/json" },
|
|
95
|
+
headers: { "Content-Type": "application/json", ...gatewayAuthHeaders() },
|
|
95
96
|
// chat_id stays in body when set — gateway uses its presence as the
|
|
96
97
|
// "explicit routing" signal. _chatId is the routing key either way.
|
|
97
98
|
body: JSON.stringify({ action, ...params, _chatId: effectiveChatId }),
|
package/src/core/types.ts
CHANGED
|
@@ -227,6 +227,19 @@ export type ExecuteParams = {
|
|
|
227
227
|
senderName: string;
|
|
228
228
|
/** Sender's platform handle without `@` (Telegram username, Discord username). */
|
|
229
229
|
senderHandle?: string;
|
|
230
|
+
/**
|
|
231
|
+
* Every key the ONE person behind this turn is known by, in operator-id
|
|
232
|
+
* form (Telegram user id, `wa_dm_<number>`, `discord:<id>`,
|
|
233
|
+
* `teams:<id>`, or `LOCAL_OPERATOR_SENDER`). Decides the turn's tool
|
|
234
|
+
* scope (core/mcp-hub/guest-scope.ts). Omit when the sender is unknown
|
|
235
|
+
* or the turn batches several senders — the turn is then guest-scoped.
|
|
236
|
+
*/
|
|
237
|
+
senderKeys?: readonly string[];
|
|
238
|
+
/**
|
|
239
|
+
* Frontend-attested: the operator is a member of this group chat. With
|
|
240
|
+
* `guestDmScope.operatorGroups` on, such a turn keeps the full surface.
|
|
241
|
+
*/
|
|
242
|
+
operatorInChat?: boolean;
|
|
230
243
|
isGroup: boolean;
|
|
231
244
|
/** Provider message ID. Numeric for Telegram, string snowflake for Discord. */
|
|
232
245
|
messageId?: number | string;
|
|
@@ -29,6 +29,12 @@ import { recordSessionTurnPhases } from "../../storage/sessions.js";
|
|
|
29
29
|
import type { TurnPhase } from "../../storage/session-record.js";
|
|
30
30
|
import { retrieveForTurn, type TurnMemory } from "../memory/turn-retrieval.js";
|
|
31
31
|
import { TalonError } from "../errors.js";
|
|
32
|
+
import { backendEnforcesGuestScope } from "../agent-runtime/backend-registry.js";
|
|
33
|
+
import {
|
|
34
|
+
enterTurnScope,
|
|
35
|
+
resolveTurnScope,
|
|
36
|
+
scopePrompt,
|
|
37
|
+
} from "../mcp-hub/guest-scope.js";
|
|
32
38
|
import { Loom } from "./loom.js";
|
|
33
39
|
import { carryTurnEvents, startShuttleTiming } from "./shuttle.js";
|
|
34
40
|
import type { Thread, ThreadSnapshot } from "./thread.js";
|
|
@@ -184,23 +190,22 @@ export class Weaver {
|
|
|
184
190
|
});
|
|
185
191
|
phases.warpResolve = Date.now() - warpStartedAt;
|
|
186
192
|
if (!warp.ok) {
|
|
187
|
-
|
|
188
|
-
// would use for output (as an `assistant_message` event, so the
|
|
189
|
-
// frontend delivers it normally).
|
|
190
|
-
try {
|
|
191
|
-
await params.onEvent?.({
|
|
192
|
-
type: "assistant_message",
|
|
193
|
-
text: warp.message,
|
|
194
|
-
});
|
|
195
|
-
} catch (err) {
|
|
196
|
-
logWarn(
|
|
197
|
-
"dispatcher",
|
|
198
|
-
`onEvent(no-model) threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
193
|
+
await deliverRefusal(params, warp.message, "no-model");
|
|
201
194
|
return this.emptyResult(warp.message, params);
|
|
202
195
|
}
|
|
203
196
|
|
|
197
|
+
// Tool scope is decided per sender: a non-operator gets the guest
|
|
198
|
+
// surface, and a backend that can't enforce it doesn't get the turn.
|
|
199
|
+
const scope = resolveTurnScope(params);
|
|
200
|
+
if (scope === "guest" && !backendEnforcesGuestScope(backend.id)) {
|
|
201
|
+
logWarn(
|
|
202
|
+
"dispatcher",
|
|
203
|
+
`[${reqId}] guest-scoped turn refused chat=${params.chatId}: backend "${backend.id}" cannot enforce the guest tool scope`,
|
|
204
|
+
);
|
|
205
|
+
await deliverRefusal(params, GUEST_BACKEND_REFUSAL, "guest-scope");
|
|
206
|
+
return this.emptyResult(GUEST_BACKEND_REFUSAL, params);
|
|
207
|
+
}
|
|
208
|
+
|
|
204
209
|
// Bind the warp — record the model/backend actually resolved for this turn
|
|
205
210
|
// on the Thread. `weaver.snapshot()` reports it, and a change since the
|
|
206
211
|
// last turn (per-chat rebind, per-run override, or config drift) is logged
|
|
@@ -235,6 +240,7 @@ export class Weaver {
|
|
|
235
240
|
`[${reqId}] ${params.source} chat=${params.chatId} started (active=${this.activeCount})`,
|
|
236
241
|
);
|
|
237
242
|
context.acquire(params.numericChatId, params.chatId);
|
|
243
|
+
const releaseScope = enterTurnScope(params.chatId, scope);
|
|
238
244
|
const stopTyping = startTypingLoop(
|
|
239
245
|
this.deps.sendTyping,
|
|
240
246
|
params.numericChatId,
|
|
@@ -251,7 +257,7 @@ export class Weaver {
|
|
|
251
257
|
const stream = backend.chat.runChatTurn({
|
|
252
258
|
chatId: params.chatId,
|
|
253
259
|
model: warp.ref,
|
|
254
|
-
text: params.prompt,
|
|
260
|
+
text: scopePrompt(scope, params.prompt),
|
|
255
261
|
senderName: params.senderName,
|
|
256
262
|
senderHandle: params.senderHandle,
|
|
257
263
|
isGroup: params.isGroup,
|
|
@@ -293,6 +299,7 @@ export class Weaver {
|
|
|
293
299
|
return this.toExecuteResult(agentResult, params);
|
|
294
300
|
} finally {
|
|
295
301
|
stopTyping();
|
|
302
|
+
releaseScope();
|
|
296
303
|
context.release(params.numericChatId, params.chatId);
|
|
297
304
|
}
|
|
298
305
|
}
|
|
@@ -331,6 +338,31 @@ export class Weaver {
|
|
|
331
338
|
}
|
|
332
339
|
}
|
|
333
340
|
|
|
341
|
+
const GUEST_BACKEND_REFUSAL =
|
|
342
|
+
"I can't answer this here: messages from anyone but the operator run with a " +
|
|
343
|
+
"limited tool set, and this chat's current model backend can't enforce it. " +
|
|
344
|
+
"The operator can switch this chat to a backend that does (Claude).";
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Refusals are delivered through the same event sink the backend would use
|
|
348
|
+
* for output (as an `assistant_message` event, so the frontend delivers it
|
|
349
|
+
* normally).
|
|
350
|
+
*/
|
|
351
|
+
async function deliverRefusal(
|
|
352
|
+
params: ExecuteParams,
|
|
353
|
+
text: string,
|
|
354
|
+
kind: string,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
try {
|
|
357
|
+
await params.onEvent?.({ type: "assistant_message", text });
|
|
358
|
+
} catch (err) {
|
|
359
|
+
logWarn(
|
|
360
|
+
"dispatcher",
|
|
361
|
+
`onEvent(${kind}) threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
334
366
|
/**
|
|
335
367
|
* This turn's retrieved memory (`core/memory/turn-retrieval.ts`) — off
|
|
336
368
|
* unless `TALON_MEMORY_STORE=1`, fail-closed, and a pure read: nothing
|
|
@@ -25,6 +25,8 @@ export type ProcessAndReplyParams = {
|
|
|
25
25
|
isGroup: boolean;
|
|
26
26
|
senderUsername?: string;
|
|
27
27
|
senderId: string;
|
|
28
|
+
/** False when the batch mixes senders — the turn is then guest-scoped. */
|
|
29
|
+
singleSender?: boolean;
|
|
28
30
|
channel: TextBasedChannel;
|
|
29
31
|
chatTitle?: string;
|
|
30
32
|
};
|
|
@@ -53,6 +55,7 @@ export async function processAndReply(p: ProcessAndReplyParams): Promise<void> {
|
|
|
53
55
|
prompt: p.prompt,
|
|
54
56
|
senderName: p.senderName,
|
|
55
57
|
senderHandle: p.senderUsername,
|
|
58
|
+
senderKeys: p.singleSender === false ? [] : [`discord:${p.senderId}`],
|
|
56
59
|
isGroup: p.isGroup,
|
|
57
60
|
// Use the real Discord snowflake string, not the hashed numeric.
|
|
58
61
|
// The hash collides with Telegram-style 32-bit IDs and Discord's API
|
|
@@ -83,6 +83,7 @@ async function flushQueue(chatId: string): Promise<void> {
|
|
|
83
83
|
isGroup: last.isGroup,
|
|
84
84
|
senderUsername: last.senderUsername,
|
|
85
85
|
senderId: last.senderId,
|
|
86
|
+
singleSender: messages.every((m) => m.senderId === last.senderId),
|
|
86
87
|
channel: last.channel,
|
|
87
88
|
chatTitle: last.chatTitle,
|
|
88
89
|
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth guard — how the bridge answers credentials that don't check out.
|
|
3
|
+
*
|
|
4
|
+
* The token itself is the security (256 bits when Talon mints it); this
|
|
5
|
+
* module decides how a wrong one is answered so an internet-facing bridge
|
|
6
|
+
* can't be hammered for free, and so the operator hears about it.
|
|
7
|
+
*
|
|
8
|
+
* Three layers, all keyed on the remote address (behind a reverse proxy that
|
|
9
|
+
* is the proxy):
|
|
10
|
+
*
|
|
11
|
+
* 1. Progressive backoff: the first few wrong tokens from an address get an
|
|
12
|
+
* immediate 401; after that each 401 waits longer (base doubling to a
|
|
13
|
+
* cap). Only failures wait. A correct token is never delayed.
|
|
14
|
+
* 2. Lockout: after `lockoutMaxFailures` wrong tokens in the window the
|
|
15
|
+
* address gets 429s (even with the right token) until the window lapses.
|
|
16
|
+
* 3. Global failure budget: if wrong tokens across ALL addresses exceed
|
|
17
|
+
* `globalMaxFailures` in `globalWindowMs`, that is distributed guessing
|
|
18
|
+
* dodging (1) and (2). The bridge enters a cooldown: wrong tokens get
|
|
19
|
+
* 429 at once, tokenless requests are slowed, the operator is alerted
|
|
20
|
+
* once. Authenticated traffic keeps working throughout.
|
|
21
|
+
*
|
|
22
|
+
* Only presented-and-wrong tokens count as failures. Tokenless probes are
|
|
23
|
+
* scanners finding a locked door. Waits are timers, never a blocked event
|
|
24
|
+
* loop, and the number of responses held at once is capped so the delays
|
|
25
|
+
* can't be turned into a socket-exhaustion lever.
|
|
26
|
+
*
|
|
27
|
+
* Every event logs one `bridge.auth event=…` line with the address and a
|
|
28
|
+
* reason. Token material never reaches this module.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { log, logWarn } from "../../../util/log.js";
|
|
32
|
+
import type { AuthState } from "./routes/table.js";
|
|
33
|
+
|
|
34
|
+
export type AuthGuardPolicy = {
|
|
35
|
+
/** Wrong tokens from one address inside the window before 429s. */
|
|
36
|
+
lockoutMaxFailures: number;
|
|
37
|
+
lockoutWindowMs: number;
|
|
38
|
+
/** Hard cap on tracked addresses so the map can't become a memory lever. */
|
|
39
|
+
maxTracked: number;
|
|
40
|
+
/** Wrong tokens answered without delay (typos happen). */
|
|
41
|
+
freeFailures: number;
|
|
42
|
+
backoffBaseMs: number;
|
|
43
|
+
backoffMaxMs: number;
|
|
44
|
+
/** Wrong tokens across every address, per window, before a cooldown. */
|
|
45
|
+
globalMaxFailures: number;
|
|
46
|
+
globalWindowMs: number;
|
|
47
|
+
globalCooldownMs: number;
|
|
48
|
+
/** How long a tokenless request is held during a cooldown. */
|
|
49
|
+
cooldownAnonDelayMs: number;
|
|
50
|
+
/** Most responses held at once; past this, refuse instead of waiting. */
|
|
51
|
+
maxPendingDelays: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DEFAULT_AUTH_GUARD_POLICY: AuthGuardPolicy = {
|
|
55
|
+
lockoutMaxFailures: 20,
|
|
56
|
+
lockoutWindowMs: 15 * 60_000,
|
|
57
|
+
maxTracked: 10_000,
|
|
58
|
+
freeFailures: 2,
|
|
59
|
+
backoffBaseMs: 250,
|
|
60
|
+
backoffMaxMs: 8_000,
|
|
61
|
+
globalMaxFailures: 100,
|
|
62
|
+
globalWindowMs: 5 * 60_000,
|
|
63
|
+
globalCooldownMs: 10 * 60_000,
|
|
64
|
+
cooldownAnonDelayMs: 1_000,
|
|
65
|
+
maxPendingDelays: 512,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type AuthVerdict =
|
|
69
|
+
| { kind: "allow" }
|
|
70
|
+
/** Hold the response this long, then carry on as normal. */
|
|
71
|
+
| { kind: "delay"; ms: number }
|
|
72
|
+
| {
|
|
73
|
+
kind: "reject";
|
|
74
|
+
reason: "lockout" | "cooldown";
|
|
75
|
+
retryAfterSec: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type Entry = { count: number; resetAt: number };
|
|
79
|
+
|
|
80
|
+
export class AuthGuard {
|
|
81
|
+
private readonly policy: AuthGuardPolicy;
|
|
82
|
+
private readonly now: () => number;
|
|
83
|
+
private readonly onAlert: ((message: string) => void) | undefined;
|
|
84
|
+
private readonly failures = new Map<string, Entry>();
|
|
85
|
+
private globalCount = 0;
|
|
86
|
+
private globalWindowStart = 0;
|
|
87
|
+
private cooldownUntil = 0;
|
|
88
|
+
private cooling = false;
|
|
89
|
+
private suppressed = 0;
|
|
90
|
+
private saturatedLogged = false;
|
|
91
|
+
private pending = 0;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
policy: Partial<AuthGuardPolicy> = {},
|
|
95
|
+
deps: { now?: () => number; onAlert?: (message: string) => void } = {},
|
|
96
|
+
) {
|
|
97
|
+
this.policy = { ...DEFAULT_AUTH_GUARD_POLICY, ...policy };
|
|
98
|
+
this.now = deps.now ?? Date.now;
|
|
99
|
+
this.onAlert = deps.onAlert;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Addresses currently tracked (tests and diagnostics). */
|
|
103
|
+
trackedCount(): number {
|
|
104
|
+
return this.failures.size;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** True while the global failure budget is exhausted. */
|
|
108
|
+
inCooldown(): boolean {
|
|
109
|
+
this.refreshCooldown(this.now());
|
|
110
|
+
return this.cooling;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decide how to answer a request whose credential has been evaluated.
|
|
115
|
+
* Called once per request, before routing.
|
|
116
|
+
*/
|
|
117
|
+
check(remote: string, auth: AuthState): AuthVerdict {
|
|
118
|
+
const now = this.now();
|
|
119
|
+
this.refreshCooldown(now);
|
|
120
|
+
const entry = this.liveEntry(remote, now);
|
|
121
|
+
if (entry && entry.count >= this.policy.lockoutMaxFailures) {
|
|
122
|
+
return {
|
|
123
|
+
kind: "reject",
|
|
124
|
+
reason: "lockout",
|
|
125
|
+
retryAfterSec: Math.max(1, Math.ceil((entry.resetAt - now) / 1000)),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (auth === "ok") {
|
|
129
|
+
this.failures.delete(remote);
|
|
130
|
+
return { kind: "allow" };
|
|
131
|
+
}
|
|
132
|
+
if (auth === "anonymous") {
|
|
133
|
+
return this.cooling
|
|
134
|
+
? { kind: "delay", ms: this.policy.cooldownAnonDelayMs }
|
|
135
|
+
: { kind: "allow" };
|
|
136
|
+
}
|
|
137
|
+
return this.fail(remote, now);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wait `ms` on a timer. Resolves false, without waiting, when too many
|
|
142
|
+
* responses are already held — the caller refuses instead.
|
|
143
|
+
*/
|
|
144
|
+
async hold(ms: number): Promise<boolean> {
|
|
145
|
+
if (this.pending >= this.policy.maxPendingDelays) return false;
|
|
146
|
+
this.pending++;
|
|
147
|
+
try {
|
|
148
|
+
await new Promise<void>((resolve) => {
|
|
149
|
+
setTimeout(resolve, ms).unref?.();
|
|
150
|
+
});
|
|
151
|
+
return true;
|
|
152
|
+
} finally {
|
|
153
|
+
this.pending--;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
private fail(remote: string, now: number): AuthVerdict {
|
|
160
|
+
const count = this.recordFailure(remote, now);
|
|
161
|
+
this.recordGlobalFailure(now);
|
|
162
|
+
if (this.cooling) {
|
|
163
|
+
this.suppressed++;
|
|
164
|
+
return {
|
|
165
|
+
kind: "reject",
|
|
166
|
+
reason: "cooldown",
|
|
167
|
+
retryAfterSec: Math.max(
|
|
168
|
+
1,
|
|
169
|
+
Math.ceil((this.cooldownUntil - now) / 1000),
|
|
170
|
+
),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const delay = count === null ? 0 : this.backoffFor(count);
|
|
174
|
+
logWarn(
|
|
175
|
+
"native",
|
|
176
|
+
`bridge.auth event=failure addr=${remote} reason=bad_token` +
|
|
177
|
+
(count === null ? " tracked=no" : ` failures=${count}`) +
|
|
178
|
+
` delayMs=${delay}`,
|
|
179
|
+
);
|
|
180
|
+
if (count === this.policy.lockoutMaxFailures) {
|
|
181
|
+
logWarn(
|
|
182
|
+
"native",
|
|
183
|
+
`bridge.auth event=lockout addr=${remote} reason=too_many_failures failures=${count} windowMin=${this.policy.lockoutWindowMs / 60_000}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return delay > 0 ? { kind: "delay", ms: delay } : { kind: "allow" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private backoffFor(count: number): number {
|
|
190
|
+
const n = count - this.policy.freeFailures;
|
|
191
|
+
if (n <= 0) return 0;
|
|
192
|
+
// 2^(n-1) overflows nothing useful past ~30 doublings; clamp first.
|
|
193
|
+
const factor = 2 ** Math.min(n - 1, 30);
|
|
194
|
+
return Math.min(
|
|
195
|
+
this.policy.backoffMaxMs,
|
|
196
|
+
this.policy.backoffBaseMs * factor,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private liveEntry(remote: string, now: number): Entry | undefined {
|
|
201
|
+
const entry = this.failures.get(remote);
|
|
202
|
+
if (entry && now >= entry.resetAt) {
|
|
203
|
+
this.failures.delete(remote);
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Bump the address's count; null when the address couldn't be tracked. */
|
|
210
|
+
private recordFailure(remote: string, now: number): number | null {
|
|
211
|
+
const entry = this.liveEntry(remote, now);
|
|
212
|
+
if (entry) return ++entry.count;
|
|
213
|
+
if (this.failures.size >= this.policy.maxTracked) {
|
|
214
|
+
for (const [ip, e] of this.failures) {
|
|
215
|
+
if (now >= e.resetAt) this.failures.delete(ip);
|
|
216
|
+
}
|
|
217
|
+
// Still saturated after pruning live entries — under that much churn
|
|
218
|
+
// dropping the newest address beats unbounded growth. The global
|
|
219
|
+
// budget still counts it.
|
|
220
|
+
if (this.failures.size >= this.policy.maxTracked) {
|
|
221
|
+
if (!this.saturatedLogged) {
|
|
222
|
+
this.saturatedLogged = true;
|
|
223
|
+
logWarn(
|
|
224
|
+
"native",
|
|
225
|
+
`bridge.auth event=tracking_saturated reason=address_cap tracked=${this.failures.size}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
this.saturatedLogged = false;
|
|
232
|
+
this.failures.set(remote, {
|
|
233
|
+
count: 1,
|
|
234
|
+
resetAt: now + this.policy.lockoutWindowMs,
|
|
235
|
+
});
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private recordGlobalFailure(now: number): void {
|
|
240
|
+
if (now - this.globalWindowStart >= this.policy.globalWindowMs) {
|
|
241
|
+
this.globalWindowStart = now;
|
|
242
|
+
this.globalCount = 0;
|
|
243
|
+
}
|
|
244
|
+
this.globalCount++;
|
|
245
|
+
if (this.globalCount <= this.policy.globalMaxFailures) return;
|
|
246
|
+
// Sustained guessing keeps pushing the end of the cooldown out.
|
|
247
|
+
this.cooldownUntil = now + this.policy.globalCooldownMs;
|
|
248
|
+
if (this.cooling) return;
|
|
249
|
+
this.cooling = true;
|
|
250
|
+
this.suppressed = 0;
|
|
251
|
+
const message =
|
|
252
|
+
`bridge.auth event=global_cooldown reason=failure_budget failures=${this.globalCount} ` +
|
|
253
|
+
`windowSec=${this.policy.globalWindowMs / 1000} cooldownSec=${this.policy.globalCooldownMs / 1000}`;
|
|
254
|
+
logWarn("native", message);
|
|
255
|
+
try {
|
|
256
|
+
this.onAlert?.(
|
|
257
|
+
`⚠️ Talon bridge: ${this.globalCount} failed auth attempts across all addresses in ` +
|
|
258
|
+
`${this.policy.globalWindowMs / 60_000} min, which looks like distributed token guessing ` +
|
|
259
|
+
"(or a token rotation left devices with a stale token). Failed and tokenless " +
|
|
260
|
+
`requests are being refused or slowed for ${this.policy.globalCooldownMs / 60_000} min; ` +
|
|
261
|
+
"paired clients are unaffected.",
|
|
262
|
+
);
|
|
263
|
+
} catch {
|
|
264
|
+
// An alert that can't be delivered must never break request handling.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private refreshCooldown(now: number): void {
|
|
269
|
+
if (!this.cooling || now < this.cooldownUntil) return;
|
|
270
|
+
this.cooling = false;
|
|
271
|
+
log(
|
|
272
|
+
"native",
|
|
273
|
+
`bridge.auth event=global_cooldown_end reason=expired refused=${this.suppressed}`,
|
|
274
|
+
);
|
|
275
|
+
this.suppressed = 0;
|
|
276
|
+
}
|
|
277
|
+
}
|