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,8 +24,14 @@ import { createServer as createTlsServer } from "node:https";
|
|
|
24
24
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
25
25
|
import { createReadStream } from "node:fs";
|
|
26
26
|
import { stat } from "node:fs/promises";
|
|
27
|
-
import { log, logError, logDebug
|
|
28
|
-
import {
|
|
27
|
+
import { log, logError, logDebug } from "../../../util/log.js";
|
|
28
|
+
import {
|
|
29
|
+
formatFingerprint,
|
|
30
|
+
isLoopbackHost,
|
|
31
|
+
type BridgeTlsIdentity,
|
|
32
|
+
} from "./tls.js";
|
|
33
|
+
import { checkBridgeTokenStrength } from "./auth.js";
|
|
34
|
+
import { AuthGuard, type AuthGuardPolicy } from "./auth-guard.js";
|
|
29
35
|
import { contentTypeFor } from "../media/media.js";
|
|
30
36
|
import { type BridgeEvent } from "../protocol.js";
|
|
31
37
|
import { buildRoutes } from "./routes/index.js";
|
|
@@ -37,39 +43,73 @@ import {
|
|
|
37
43
|
type RouteContext,
|
|
38
44
|
type RouteHandler,
|
|
39
45
|
} from "./routes/table.js";
|
|
46
|
+
import {
|
|
47
|
+
describeTier,
|
|
48
|
+
hasScope,
|
|
49
|
+
resolvePrincipal,
|
|
50
|
+
routeAllows,
|
|
51
|
+
type BridgeCredentials,
|
|
52
|
+
type BridgePrincipal,
|
|
53
|
+
} from "./credentials/principal.js";
|
|
40
54
|
|
|
41
55
|
export type { BridgeServerHandlers, SendOptions } from "./routes/host.js";
|
|
42
56
|
export { BRIDGE_ROUTE_AUTH, type BridgeRouteKey } from "./routes/table.js";
|
|
57
|
+
export type { BridgeCredentials } from "./credentials/principal.js";
|
|
58
|
+
|
|
59
|
+
/** One live SSE connection: the device it claimed and who opened it. */
|
|
60
|
+
type StreamSession = {
|
|
61
|
+
deviceId: string | undefined;
|
|
62
|
+
principal: BridgePrincipal;
|
|
63
|
+
};
|
|
43
64
|
|
|
44
65
|
const SSE_PING_MS = 25_000;
|
|
45
66
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
46
67
|
const PORT_FALLBACKS = 5;
|
|
47
68
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Server-level socket deadlines (slow-loris resistance). Node applies
|
|
71
|
+
* `requestTimeout` only until the request has been fully RECEIVED, so the
|
|
72
|
+
* long-lived SSE stream (a bodyless GET) and file downloads (response
|
|
73
|
+
* bodies) are unaffected. It does bound request bodies, and every
|
|
74
|
+
* body-reading route sits behind the bearer check (an unauthenticated
|
|
75
|
+
* request is answered 401/429 with `Connection: close` before its body is
|
|
76
|
+
* read), so the budget is sized for the largest authenticated upload
|
|
77
|
+
* (512 MB) on a slow link, not for an attacker.
|
|
78
|
+
*/
|
|
79
|
+
export type BridgeTimeouts = {
|
|
80
|
+
/** Whole header block must arrive within this. Node default: 60s. */
|
|
81
|
+
headersMs: number;
|
|
82
|
+
/** Whole request (headers + body) must arrive within this. Node: 300s. */
|
|
83
|
+
requestMs: number;
|
|
84
|
+
/** Idle keep-alive sockets are closed after this. */
|
|
85
|
+
keepAliveMs: number;
|
|
86
|
+
/** How often Node sweeps for expired header/request deadlines. */
|
|
87
|
+
checkIntervalMs: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const DEFAULT_BRIDGE_TIMEOUTS: BridgeTimeouts = {
|
|
91
|
+
headersMs: 20_000,
|
|
92
|
+
requestMs: 30 * 60_000,
|
|
93
|
+
keepAliveMs: 5_000,
|
|
94
|
+
checkIntervalMs: 30_000,
|
|
95
|
+
};
|
|
58
96
|
|
|
59
97
|
export class BridgeServer {
|
|
60
98
|
private server: Server | null = null;
|
|
61
99
|
/**
|
|
62
100
|
* Live SSE connections → the mesh device id each one claimed on connect
|
|
63
101
|
* (undefined for clients that didn't claim one: desktop UIs, and companion
|
|
64
|
-
* builds from before the claim existed)
|
|
65
|
-
* `sendToDevice` addressable rather than a shout
|
|
102
|
+
* builds from before the claim existed) and the principal that opened it.
|
|
103
|
+
* The claim is what makes `sendToDevice` addressable rather than a shout;
|
|
104
|
+
* the principal is what lets a revocation find and drop the session.
|
|
66
105
|
*/
|
|
67
|
-
private clients = new Map<ServerResponse,
|
|
106
|
+
private clients = new Map<ServerResponse, StreamSession>();
|
|
107
|
+
private unsubscribeRevocations: (() => void) | undefined;
|
|
68
108
|
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
69
109
|
private port = 0;
|
|
70
110
|
private tlsIdentity: BridgeTlsIdentity | null = null;
|
|
71
|
-
/**
|
|
72
|
-
private
|
|
111
|
+
/** Backoff, lockout and the global failure budget for wrong tokens. */
|
|
112
|
+
private readonly authGuard: AuthGuard;
|
|
73
113
|
/** `METHOD /path` → handler; a Map so lookups only ever hit own entries. */
|
|
74
114
|
private readonly routes: ReadonlyMap<BridgeRouteKey, RouteHandler>;
|
|
75
115
|
|
|
@@ -88,9 +128,30 @@ export class BridgeServer {
|
|
|
88
128
|
* I/O — it resolves once, inside `start()`.
|
|
89
129
|
*/
|
|
90
130
|
tls?: () => Promise<BridgeTlsIdentity>;
|
|
131
|
+
/**
|
|
132
|
+
* Per-device credentials (core/mesh/credentials) and the migration
|
|
133
|
+
* policy. Absent: the shared `token` is the only credential, as
|
|
134
|
+
* before.
|
|
135
|
+
*/
|
|
136
|
+
credentials?: BridgeCredentials;
|
|
137
|
+
/** `native.allowWeakToken`: start on a public bind despite a weak token. */
|
|
138
|
+
allowWeakToken?: boolean;
|
|
139
|
+
/** Operator alert for security events (the global auth cooldown). */
|
|
140
|
+
onSecurityAlert?: (message: string) => void;
|
|
141
|
+
/**
|
|
142
|
+
* `native.sseMaxLifetimeMs`: end each event stream after this long
|
|
143
|
+
* (±10% jitter) so clients re-authenticate. Unset = never.
|
|
144
|
+
*/
|
|
145
|
+
sseMaxLifetimeMs?: number;
|
|
146
|
+
/** Overrides for tests; production uses the defaults. */
|
|
147
|
+
authPolicy?: Partial<AuthGuardPolicy>;
|
|
148
|
+
timeouts?: Partial<BridgeTimeouts>;
|
|
91
149
|
},
|
|
92
150
|
private readonly handlers: BridgeServerHandlers,
|
|
93
151
|
) {
|
|
152
|
+
this.authGuard = new AuthGuard(opts.authPolicy, {
|
|
153
|
+
onAlert: opts.onSecurityAlert,
|
|
154
|
+
});
|
|
94
155
|
this.routes = new Map(
|
|
95
156
|
Object.entries(buildRoutes(this.routeHost())) as [
|
|
96
157
|
BridgeRouteKey,
|
|
@@ -113,10 +174,49 @@ export class BridgeServer {
|
|
|
113
174
|
return this.tlsIdentity?.fingerprint ?? null;
|
|
114
175
|
}
|
|
115
176
|
|
|
116
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Push an event to every connected SSE client that may see it. Chat
|
|
179
|
+
* traffic is for `client`-scoped sessions only; a device-only credential
|
|
180
|
+
* hears the mesh-wide `locate` and nothing else.
|
|
181
|
+
*/
|
|
117
182
|
broadcast(event: BridgeEvent): void {
|
|
118
183
|
if (this.clients.size === 0) return;
|
|
119
|
-
|
|
184
|
+
const targets: ServerResponse[] = [];
|
|
185
|
+
for (const [res, session] of this.clients) {
|
|
186
|
+
if (event.kind === "locate" || hasScope(session.principal, "client")) {
|
|
187
|
+
targets.push(res);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
this.write(targets, event);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* End every live stream opened with one of `credentialIds` — revocation
|
|
195
|
+
* (and a scope change) must bite now, not at the client's next request.
|
|
196
|
+
*/
|
|
197
|
+
dropCredentialSessions(credentialIds: readonly string[]): number {
|
|
198
|
+
let dropped = 0;
|
|
199
|
+
for (const [res, { principal }] of this.clients) {
|
|
200
|
+
if (
|
|
201
|
+
principal.kind === "device" &&
|
|
202
|
+
credentialIds.includes(principal.credentialId)
|
|
203
|
+
) {
|
|
204
|
+
this.clients.delete(res);
|
|
205
|
+
try {
|
|
206
|
+
res.end();
|
|
207
|
+
} catch {
|
|
208
|
+
/* already gone */
|
|
209
|
+
}
|
|
210
|
+
dropped++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (dropped > 0) {
|
|
214
|
+
log(
|
|
215
|
+
"native",
|
|
216
|
+
`Dropped ${dropped} live session(s) of revoked/changed credential(s) ${credentialIds.join(", ")}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return dropped;
|
|
120
220
|
}
|
|
121
221
|
|
|
122
222
|
/**
|
|
@@ -138,15 +238,21 @@ export class BridgeServer {
|
|
|
138
238
|
* target claimed nothing either: a companion build that predates the claim
|
|
139
239
|
* can't be addressed, and dropping its commands would take the mesh offline
|
|
140
240
|
* for it. So an updated device's traffic never reaches them — the fallback
|
|
141
|
-
* shrinks to nothing as the fleet updates.
|
|
241
|
+
* shrinks to nothing as the fleet updates. Only shared-token sessions are
|
|
242
|
+
* in that fallback: a per-device credential is its own device or nobody.
|
|
243
|
+
*
|
|
244
|
+
* With per-device credentials the claim IS enforced: a credential can
|
|
245
|
+
* only claim the device it is bound to (credentials/claims.ts).
|
|
142
246
|
*/
|
|
143
247
|
sendToDevice(deviceId: string, event: BridgeEvent): void {
|
|
144
248
|
if (this.clients.size === 0) return;
|
|
145
249
|
const claimed: ServerResponse[] = [];
|
|
146
250
|
const unclaimed: ServerResponse[] = [];
|
|
147
|
-
for (const [res, id] of this.clients) {
|
|
251
|
+
for (const [res, { deviceId: id, principal }] of this.clients) {
|
|
148
252
|
if (id === deviceId) claimed.push(res);
|
|
149
|
-
else if (id === undefined
|
|
253
|
+
else if (id === undefined && principal.kind !== "device") {
|
|
254
|
+
unclaimed.push(res);
|
|
255
|
+
}
|
|
150
256
|
}
|
|
151
257
|
if (claimed.length === 0) {
|
|
152
258
|
logDebug(
|
|
@@ -170,6 +276,11 @@ export class BridgeServer {
|
|
|
170
276
|
|
|
171
277
|
async start(): Promise<number> {
|
|
172
278
|
if (this.server) return this.port;
|
|
279
|
+
checkBridgeTokenStrength({
|
|
280
|
+
token: this.opts.token,
|
|
281
|
+
loopback: isLoopbackHost(this.opts.host),
|
|
282
|
+
allowWeakToken: this.opts.allowWeakToken,
|
|
283
|
+
});
|
|
173
284
|
this.tlsIdentity = this.opts.tls ? await this.opts.tls() : null;
|
|
174
285
|
const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
|
|
175
286
|
this.handle(req, res).catch((err) => {
|
|
@@ -182,12 +293,23 @@ export class BridgeServer {
|
|
|
182
293
|
};
|
|
183
294
|
// https.Server extends http.Server's request/lifecycle surface — one
|
|
184
295
|
// `Server`-typed field serves both transports.
|
|
296
|
+
const t = { ...DEFAULT_BRIDGE_TIMEOUTS, ...this.opts.timeouts };
|
|
297
|
+
const serverOpts = {
|
|
298
|
+
headersTimeout: t.headersMs,
|
|
299
|
+
requestTimeout: t.requestMs,
|
|
300
|
+
keepAliveTimeout: t.keepAliveMs,
|
|
301
|
+
connectionsCheckingInterval: t.checkIntervalMs,
|
|
302
|
+
};
|
|
185
303
|
const server: Server = this.tlsIdentity
|
|
186
304
|
? createTlsServer(
|
|
187
|
-
{
|
|
305
|
+
{
|
|
306
|
+
...serverOpts,
|
|
307
|
+
key: this.tlsIdentity.keyPem,
|
|
308
|
+
cert: this.tlsIdentity.certPem,
|
|
309
|
+
},
|
|
188
310
|
onRequest,
|
|
189
311
|
)
|
|
190
|
-
: createServer(onRequest);
|
|
312
|
+
: createServer(serverOpts, onRequest);
|
|
191
313
|
|
|
192
314
|
this.pingTimer = setInterval(() => {
|
|
193
315
|
for (const res of this.clients.keys()) {
|
|
@@ -199,6 +321,9 @@ export class BridgeServer {
|
|
|
199
321
|
}
|
|
200
322
|
}, SSE_PING_MS);
|
|
201
323
|
this.pingTimer.unref?.();
|
|
324
|
+
this.unsubscribeRevocations = this.opts.credentials?.authority.onRevoked(
|
|
325
|
+
(ids) => this.dropCredentialSessions(ids),
|
|
326
|
+
);
|
|
202
327
|
|
|
203
328
|
return new Promise<number>((resolve, reject) => {
|
|
204
329
|
let attempt = 0;
|
|
@@ -246,6 +371,8 @@ export class BridgeServer {
|
|
|
246
371
|
|
|
247
372
|
async stop(): Promise<void> {
|
|
248
373
|
clearInterval(this.pingTimer);
|
|
374
|
+
this.unsubscribeRevocations?.();
|
|
375
|
+
this.unsubscribeRevocations = undefined;
|
|
249
376
|
for (const res of this.clients.keys()) {
|
|
250
377
|
try {
|
|
251
378
|
res.end();
|
|
@@ -304,35 +431,34 @@ export class BridgeServer {
|
|
|
304
431
|
}
|
|
305
432
|
|
|
306
433
|
const remote = req.socket.remoteAddress ?? "unknown";
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
...this.jsonHeaders(),
|
|
310
|
-
"Retry-After": String(Math.ceil(AUTH_LOCKOUT_WINDOW_MS / 1000)),
|
|
311
|
-
});
|
|
312
|
-
res.end(
|
|
313
|
-
JSON.stringify({ ok: false, error: "Too many failed auth attempts" }),
|
|
314
|
-
);
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const auth = this.authState(req, url);
|
|
319
|
-
if (auth === "bad") this.recordAuthFailure(remote);
|
|
320
|
-
else if (auth === "ok") this.authFailures.delete(remote);
|
|
434
|
+
const { state: auth, principal } = this.authState(req, url);
|
|
435
|
+
if (!(await this.admit(res, remote, auth))) return;
|
|
321
436
|
|
|
322
437
|
const key = `${method} ${path}` as BridgeRouteKey;
|
|
323
438
|
const route = this.routes.get(key);
|
|
324
|
-
const ctx: RouteContext = { req, res, url, auth };
|
|
439
|
+
const ctx: RouteContext = { req, res, url, auth, principal };
|
|
440
|
+
const tier = route ? BRIDGE_ROUTE_AUTH[key] : undefined;
|
|
325
441
|
|
|
326
|
-
if (route &&
|
|
442
|
+
if (route && tier === "public") {
|
|
327
443
|
await route(ctx);
|
|
328
444
|
return;
|
|
329
445
|
}
|
|
330
446
|
// Unknown routes are 401 before they are 404: an unauthenticated caller
|
|
331
447
|
// learns nothing about the route map.
|
|
332
|
-
if (auth !== "ok") {
|
|
333
|
-
return this.
|
|
448
|
+
if (auth !== "ok" || principal === null) {
|
|
449
|
+
return this.refuse(res, 401, "Unauthorized");
|
|
450
|
+
}
|
|
451
|
+
if (!route || tier === undefined) {
|
|
452
|
+
return this.json(res, 404, { ok: false, error: "Not found" });
|
|
453
|
+
}
|
|
454
|
+
// Authenticated, but is this credential allowed HERE? The scope each
|
|
455
|
+
// route needs is declared in routes/table.ts.
|
|
456
|
+
if (!routeAllows(tier, principal)) {
|
|
457
|
+
return this.json(res, 403, {
|
|
458
|
+
ok: false,
|
|
459
|
+
error: `This credential lacks the ${describeTier(tier)} scope ${key} requires`,
|
|
460
|
+
});
|
|
334
461
|
}
|
|
335
|
-
if (!route) return this.json(res, 404, { ok: false, error: "Not found" });
|
|
336
462
|
|
|
337
463
|
try {
|
|
338
464
|
await route(ctx);
|
|
@@ -355,7 +481,9 @@ export class BridgeServer {
|
|
|
355
481
|
corsHeaders: () => this.corsHeaders(),
|
|
356
482
|
streamFile: (res, file) => this.streamFile(res, file),
|
|
357
483
|
serveMedia: (res, id) => this.serveMedia(res, id),
|
|
358
|
-
openStream: (res, deviceId) =>
|
|
484
|
+
openStream: (res, deviceId, principal) =>
|
|
485
|
+
this.openStream(res, deviceId, principal),
|
|
486
|
+
credentials: this.opts.credentials,
|
|
359
487
|
unknownProvision: (res) => this.unknownProvision(res),
|
|
360
488
|
};
|
|
361
489
|
}
|
|
@@ -410,7 +538,13 @@ export class BridgeServer {
|
|
|
410
538
|
}
|
|
411
539
|
}
|
|
412
540
|
|
|
413
|
-
private openStream(
|
|
541
|
+
private openStream(
|
|
542
|
+
res: ServerResponse,
|
|
543
|
+
deviceId: string | undefined,
|
|
544
|
+
principal: BridgePrincipal,
|
|
545
|
+
): void {
|
|
546
|
+
// A device-only credential gets its own mesh traffic, not the chats.
|
|
547
|
+
const seesChats = hasScope(principal, "client");
|
|
414
548
|
res.writeHead(200, {
|
|
415
549
|
...this.corsHeaders(),
|
|
416
550
|
"Content-Type": "text/event-stream",
|
|
@@ -424,34 +558,60 @@ export class BridgeServer {
|
|
|
424
558
|
`data: ${JSON.stringify({
|
|
425
559
|
kind: "hello",
|
|
426
560
|
status: this.handlers.status(),
|
|
427
|
-
chats: this.handlers.listChats(),
|
|
561
|
+
chats: seesChats ? this.handlers.listChats() : [],
|
|
428
562
|
})}\n\n`,
|
|
429
563
|
);
|
|
430
564
|
// Replay any in-progress turn so a client that connected mid-turn (or
|
|
431
565
|
// reconnected after a blip) sees the tool timeline immediately, not just
|
|
432
566
|
// the tools that fire after it joined.
|
|
433
567
|
try {
|
|
434
|
-
for (const event of this.handlers.liveTurnEvents()) {
|
|
568
|
+
for (const event of seesChats ? this.handlers.liveTurnEvents() : []) {
|
|
435
569
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
436
570
|
}
|
|
437
571
|
} catch (err) {
|
|
438
572
|
logError("native", "Failed to replay live turn to new client", err);
|
|
439
573
|
}
|
|
440
|
-
this.clients.set(res, deviceId);
|
|
574
|
+
this.clients.set(res, { deviceId, principal });
|
|
441
575
|
logDebug(
|
|
442
576
|
"native",
|
|
443
577
|
`SSE client connected${deviceId ? ` as device ${deviceId}` : ""} (${this.clients.size} total)`,
|
|
444
578
|
);
|
|
579
|
+
const lifetime = this.sseLifetimeTimer(res);
|
|
445
580
|
res.on("close", () => {
|
|
581
|
+
clearTimeout(lifetime);
|
|
446
582
|
this.clients.delete(res);
|
|
447
583
|
logDebug("native", `SSE client left (${this.clients.size} total)`);
|
|
448
584
|
});
|
|
449
585
|
}
|
|
450
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Optional max lifetime for an event stream: ending it makes the client
|
|
589
|
+
* reconnect and present its token again. Off unless configured —
|
|
590
|
+
* clients do reconnect, but with their own backoff, and a device command
|
|
591
|
+
* sent in that gap is lost.
|
|
592
|
+
*/
|
|
593
|
+
private sseLifetimeTimer(
|
|
594
|
+
res: ServerResponse,
|
|
595
|
+
): ReturnType<typeof setTimeout> | undefined {
|
|
596
|
+
const max = this.opts.sseMaxLifetimeMs;
|
|
597
|
+
if (!max || max <= 0) return undefined;
|
|
598
|
+
// Jitter so a fleet that connected together doesn't reconnect together.
|
|
599
|
+
const ms = Math.round(max * (0.9 + Math.random() * 0.2));
|
|
600
|
+
const timer = setTimeout(() => {
|
|
601
|
+
logDebug("native", "bridge.sse event=max_lifetime reason=expired");
|
|
602
|
+
res.end();
|
|
603
|
+
}, ms);
|
|
604
|
+
timer.unref?.();
|
|
605
|
+
return timer;
|
|
606
|
+
}
|
|
607
|
+
|
|
451
608
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
452
609
|
|
|
453
|
-
private authState(
|
|
454
|
-
|
|
610
|
+
private authState(
|
|
611
|
+
req: IncomingMessage,
|
|
612
|
+
url: URL,
|
|
613
|
+
): { state: AuthState; principal: BridgePrincipal | null } {
|
|
614
|
+
if (!this.opts.token) return { state: "ok", principal: { kind: "open" } };
|
|
455
615
|
const header = req.headers["authorization"];
|
|
456
616
|
const fromHeader =
|
|
457
617
|
typeof header === "string" && header.startsWith("Bearer ")
|
|
@@ -459,45 +619,68 @@ export class BridgeServer {
|
|
|
459
619
|
: null;
|
|
460
620
|
// EventSource can't set headers, so SSE clients pass ?token=… instead.
|
|
461
621
|
const candidate = fromHeader ?? url.searchParams.get("token");
|
|
462
|
-
if (candidate === null) return "anonymous";
|
|
463
|
-
|
|
622
|
+
if (candidate === null) return { state: "anonymous", principal: null };
|
|
623
|
+
// The shared token or a per-device credential (credentials/principal.ts).
|
|
624
|
+
const principal = resolvePrincipal(
|
|
625
|
+
candidate,
|
|
626
|
+
req,
|
|
627
|
+
(c) => this.tokenMatches(c),
|
|
628
|
+
this.opts.credentials,
|
|
629
|
+
);
|
|
630
|
+
return principal
|
|
631
|
+
? { state: "ok", principal }
|
|
632
|
+
: { state: "bad", principal: null };
|
|
464
633
|
}
|
|
465
634
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
635
|
+
/**
|
|
636
|
+
* Apply the auth guard's verdict. Returns false once it has answered the
|
|
637
|
+
* request itself (429), true when routing should continue — after any
|
|
638
|
+
* backoff wait has elapsed.
|
|
639
|
+
*/
|
|
640
|
+
private async admit(
|
|
641
|
+
res: ServerResponse,
|
|
642
|
+
remote: string,
|
|
643
|
+
auth: AuthState,
|
|
644
|
+
): Promise<boolean> {
|
|
645
|
+
const verdict = this.authGuard.check(remote, auth);
|
|
646
|
+
if (verdict.kind === "reject") {
|
|
647
|
+
this.refuse(
|
|
648
|
+
res,
|
|
649
|
+
429,
|
|
650
|
+
verdict.reason === "lockout"
|
|
651
|
+
? "Too many failed auth attempts"
|
|
652
|
+
: "Too many failed auth attempts; try again later",
|
|
653
|
+
verdict.retryAfterSec,
|
|
654
|
+
);
|
|
471
655
|
return false;
|
|
472
656
|
}
|
|
473
|
-
|
|
657
|
+
if (verdict.kind === "delay" && !(await this.authGuard.hold(verdict.ms))) {
|
|
658
|
+
// Too many responses already held: refuse now rather than queue more.
|
|
659
|
+
this.refuse(res, 429, "Too many failed auth attempts", 1);
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
// The client may have hung up while we waited.
|
|
663
|
+
return !res.destroyed;
|
|
474
664
|
}
|
|
475
665
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
entry.count++;
|
|
495
|
-
if (entry.count === AUTH_LOCKOUT_MAX_FAILURES) {
|
|
496
|
-
logWarn(
|
|
497
|
-
"native",
|
|
498
|
-
`Bridge auth lockout for ${remote} (${AUTH_LOCKOUT_MAX_FAILURES} wrong tokens in ${AUTH_LOCKOUT_WINDOW_MS / 60_000}m)`,
|
|
499
|
-
);
|
|
500
|
-
}
|
|
666
|
+
/**
|
|
667
|
+
* An auth refusal. `Connection: close` so an unauthenticated caller
|
|
668
|
+
* can't keep the socket, or dribble an unread request body into it.
|
|
669
|
+
*/
|
|
670
|
+
private refuse(
|
|
671
|
+
res: ServerResponse,
|
|
672
|
+
code: 401 | 429,
|
|
673
|
+
error: string,
|
|
674
|
+
retryAfterSec?: number,
|
|
675
|
+
): void {
|
|
676
|
+
res.writeHead(code, {
|
|
677
|
+
...this.jsonHeaders(),
|
|
678
|
+
Connection: "close",
|
|
679
|
+
...(retryAfterSec !== undefined
|
|
680
|
+
? { "Retry-After": String(retryAfterSec) }
|
|
681
|
+
: {}),
|
|
682
|
+
});
|
|
683
|
+
res.end(JSON.stringify({ ok: false, error }));
|
|
501
684
|
}
|
|
502
685
|
|
|
503
686
|
/**
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import type { TalonConfig } from "../../core/config/index.js";
|
|
18
18
|
import type { ContextManager } from "../../core/types.js";
|
|
19
19
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
20
|
-
import { log, logError } from "../../util/log.js";
|
|
20
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
21
|
+
import { notifyAdmin } from "../../core/frontend-runtime/admin-notify.js";
|
|
21
22
|
import { createNativeActionHandler } from "./turn/actions.js";
|
|
22
23
|
import { loadOrCreateBridgeToken } from "./bridge/auth.js";
|
|
23
24
|
import { warmContextCache } from "./turn/context.js";
|
|
@@ -29,7 +30,11 @@ import { emitAssistant, emitPhoto } from "./turn/emit.js";
|
|
|
29
30
|
import { startEmptyChatSweep } from "./chats/empty-chat-sweep.js";
|
|
30
31
|
import { buildBridgeHandlers } from "./surface/handlers.js";
|
|
31
32
|
import { createNativeRuntime, type NativeRuntime } from "./runtime.js";
|
|
32
|
-
import { BridgeServer } from "./bridge/server.js";
|
|
33
|
+
import { BridgeServer, type BridgeCredentials } from "./bridge/server.js";
|
|
34
|
+
import {
|
|
35
|
+
DEFAULT_COMPANION_SCOPES,
|
|
36
|
+
type MeshScope,
|
|
37
|
+
} from "../../core/mesh/credentials/index.js";
|
|
33
38
|
import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./bridge/tls.js";
|
|
34
39
|
|
|
35
40
|
export { summarizeToolResult } from "./turn/tool-result.js";
|
|
@@ -50,6 +55,8 @@ type BridgeListen = {
|
|
|
50
55
|
port: number;
|
|
51
56
|
token: string | undefined;
|
|
52
57
|
tls: boolean;
|
|
58
|
+
legacySharedToken: boolean;
|
|
59
|
+
companionScopes: readonly MeshScope[];
|
|
53
60
|
};
|
|
54
61
|
|
|
55
62
|
/** Where the bridge listens, and how it is secured, from `config.native`. */
|
|
@@ -71,7 +78,32 @@ function bridgeListen(config: TalonConfig): BridgeListen {
|
|
|
71
78
|
const token =
|
|
72
79
|
configuredToken ??
|
|
73
80
|
(isLoopbackHost(host) ? undefined : loadOrCreateBridgeToken());
|
|
74
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
host,
|
|
83
|
+
port: nativeCfg.port ?? 19880,
|
|
84
|
+
token,
|
|
85
|
+
tls,
|
|
86
|
+
legacySharedToken: nativeCfg.legacySharedToken ?? true,
|
|
87
|
+
companionScopes: nativeCfg.companionScopes ?? DEFAULT_COMPANION_SCOPES,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-device credentials for the bridge: only meaningful when the bridge
|
|
93
|
+
* authenticates at all (an open loopback bridge has nothing to scope).
|
|
94
|
+
*/
|
|
95
|
+
function bridgeCredentials(
|
|
96
|
+
listen: BridgeListen,
|
|
97
|
+
mesh: NativeRuntime["mesh"],
|
|
98
|
+
): BridgeCredentials | undefined {
|
|
99
|
+
if (!listen.token || !mesh.credentials) return undefined;
|
|
100
|
+
return {
|
|
101
|
+
authority: mesh.credentials,
|
|
102
|
+
policy: {
|
|
103
|
+
legacySharedToken: listen.legacySharedToken,
|
|
104
|
+
companionScopes: listen.companionScopes,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
/**
|
|
@@ -116,8 +148,13 @@ export function createNativeFrontend(
|
|
|
116
148
|
port: listen.port,
|
|
117
149
|
token: listen.token,
|
|
118
150
|
allowedOrigins: config.native?.allowedOrigins,
|
|
151
|
+
allowWeakToken: config.native?.allowWeakToken,
|
|
152
|
+
sseMaxLifetimeMs: config.native?.sseMaxLifetimeMs,
|
|
153
|
+
// Distributed token guessing should reach a human, not just the log.
|
|
154
|
+
onSecurityAlert: (message) => void notifyAdmin(message),
|
|
119
155
|
startedAt: runtime.startedAt,
|
|
120
156
|
...(listen.tls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
|
|
157
|
+
credentials: bridgeCredentials(listen, mesh),
|
|
121
158
|
},
|
|
122
159
|
buildBridgeHandlers(runtime),
|
|
123
160
|
);
|
|
@@ -176,6 +213,18 @@ export function createNativeFrontend(
|
|
|
176
213
|
);
|
|
177
214
|
stopEmptyChatSweep = startEmptyChatSweep(runtime);
|
|
178
215
|
await server.start();
|
|
216
|
+
if (
|
|
217
|
+
listen.token &&
|
|
218
|
+
listen.legacySharedToken &&
|
|
219
|
+
!isLoopbackHost(listen.host)
|
|
220
|
+
) {
|
|
221
|
+
logWarn(
|
|
222
|
+
"native",
|
|
223
|
+
"native.legacySharedToken is on: remote clients may still use the shared bridge token. " +
|
|
224
|
+
"Devices trade it for their own credential on their next connect; once `talon mesh` " +
|
|
225
|
+
"lists none on the shared token, set native.legacySharedToken to false and rotate native.token.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
179
228
|
const fingerprint = server.getFingerprint();
|
|
180
229
|
// Tell the mesh how this bridge is reachable — everything a generated
|
|
181
230
|
// node installer needs (make_node_install_link fails cleanly without it).
|
|
@@ -188,6 +237,8 @@ export function createNativeFrontend(
|
|
|
188
237
|
...(config.native?.publicUrl
|
|
189
238
|
? { publicUrl: config.native.publicUrl }
|
|
190
239
|
: {}),
|
|
240
|
+
legacySharedToken: listen.legacySharedToken,
|
|
241
|
+
companionScopes: listen.companionScopes,
|
|
191
242
|
});
|
|
192
243
|
await writeBridgeDiscovery({
|
|
193
244
|
port: server.getPort(),
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { ExecuteResult } from "../../../core/types.js";
|
|
10
10
|
import type { AgentEvent } from "../../../core/agent-runtime/events.js";
|
|
11
11
|
import { execute } from "../../../core/engine/dispatcher.js";
|
|
12
|
+
import { LOCAL_OPERATOR_SENDER } from "../../../core/mcp-hub/guest-scope.js";
|
|
12
13
|
import { toolInputToRecord } from "../../../core/agent-runtime/events.js";
|
|
13
14
|
import { getBackendForChat } from "../../../core/engine/backend-controller/index.js";
|
|
14
15
|
import { isDeliveryTool } from "../../../core/tools/index.js";
|
|
@@ -294,6 +295,7 @@ async function runTurn(
|
|
|
294
295
|
numericChatId: entry.numericId,
|
|
295
296
|
prompt,
|
|
296
297
|
senderName: "User",
|
|
298
|
+
senderKeys: [LOCAL_OPERATOR_SENDER],
|
|
297
299
|
isGroup: false,
|
|
298
300
|
// Give the model the user message's id so `[msg_id:N]` is present and
|
|
299
301
|
// react/reply/edit target the user's message — not the bot's.
|