talon-agent 3.8.2 → 3.9.0
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/package.json +1 -2
- package/src/backend/claude-sdk/handler.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -1
- package/src/backend/kilo/handler/index.ts +1 -2
- package/src/backend/kilo/handler/state.ts +3 -8
- package/src/backend/kilo/index.ts +2 -28
- package/src/backend/openai-agents/handler/message.ts +1 -1
- package/src/backend/openai-agents/session.ts +1 -1
- package/src/backend/opencode/handler/index.ts +1 -2
- package/src/backend/opencode/handler/state.ts +3 -8
- package/src/backend/opencode/index.ts +0 -7
- package/src/backend/shared/delivery.ts +1 -1
- package/src/backend/shared/handle-retry.ts +1 -1
- package/src/backend/shared/index.ts +3 -2
- package/src/backend/shared/turn-interrupt.ts +1 -1
- package/src/cli/index.ts +0 -8
- package/src/core/bus/index.ts +1 -9
- package/src/core/mesh/registry.ts +106 -4
- package/src/core/mesh/service.ts +22 -7
- package/src/core/mesh/transfers.ts +33 -4
- package/src/core/plugin/index.ts +0 -9
- package/src/core/prompt/index.ts +1 -16
- package/src/core/vfs/index.ts +2 -21
- package/src/core/weaver/index.ts +5 -9
- package/src/frontend/discord/actions/messaging.ts +73 -13
- package/src/frontend/discord/actions/shared.ts +21 -3
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/errors.ts +16 -5
- package/src/frontend/discord/formatting.ts +19 -4
- package/src/frontend/native/index.ts +22 -8
- package/src/frontend/native/protocol.ts +6 -0
- package/src/frontend/native/server.ts +171 -12
- package/src/frontend/shared/format.ts +6 -0
- package/src/frontend/telegram/actions/messaging.ts +120 -24
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/formatting.ts +64 -19
- package/src/frontend/terminal/commands.ts +46 -8
- package/src/frontend/terminal/input.ts +110 -12
- package/src/frontend/terminal/renderer.ts +2 -0
- package/src/{util → storage}/metrics.ts +1 -1
- package/src/storage/scheduled-store.ts +6 -3
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/backend/codex/index.ts +0 -33
- package/src/backend/openai-agents/index.ts +0 -48
- package/src/cli/plugin-entries.ts +0 -104
- package/src/core/agent-runtime/index.ts +0 -62
- package/src/core/background/index.ts +0 -21
- package/src/core/engine/index.ts +0 -20
- package/src/core/models/index.ts +0 -17
- package/src/core/soul/index.ts +0 -140
- package/src/core/tools/mcp-server.ts +0 -111
- /package/src/{backend/shared → util}/session-name.ts +0 -0
package/src/core/weaver/index.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
export { Thread, type
|
|
2
|
-
|
|
1
|
+
export { Thread, type ThreadSnapshot } from "./thread.js";
|
|
2
|
+
|
|
3
3
|
export { Loom, type ContextRegistry } from "./loom.js";
|
|
4
|
-
export { carryTurnEvents
|
|
5
|
-
export { startTypingLoop
|
|
6
|
-
export {
|
|
7
|
-
resolveWarp,
|
|
8
|
-
type WarpResolution,
|
|
9
|
-
type WarpResolverDeps,
|
|
10
|
-
} from "./warp-resolver.js";
|
|
4
|
+
export { carryTurnEvents } from "./shuttle.js";
|
|
5
|
+
export { startTypingLoop } from "./typing-loop.js";
|
|
6
|
+
export { resolveWarp } from "./warp-resolver.js";
|
|
11
7
|
export {
|
|
12
8
|
Weaver,
|
|
13
9
|
getActiveLoom,
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
import type { Client } from "discord.js";
|
|
7
7
|
import { withRetry } from "../../../core/engine/gateway.js";
|
|
8
8
|
import { sendChunked } from "../handlers/index.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
suppressMentions,
|
|
11
|
+
splitMessage,
|
|
12
|
+
DISCORD_MAX_TEXT,
|
|
13
|
+
} from "../formatting.js";
|
|
10
14
|
import { log, logError } from "../../../util/log.js";
|
|
11
15
|
import {
|
|
12
16
|
saveScheduled,
|
|
@@ -24,6 +28,41 @@ import type { DiscordActionHandlers } from "./types.js";
|
|
|
24
28
|
/** Longest schedulable delay: 24h. Timers re-arm from the store on boot. */
|
|
25
29
|
const MAX_DELAY_SEC = 24 * 60 * 60;
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Deliver one scheduled entry, replaying whatever it was scheduled with.
|
|
33
|
+
*
|
|
34
|
+
* The `send` tool documents that "buttons and reply threading survive the
|
|
35
|
+
* delay — the schedule handler replays them at fire time". Discord's path
|
|
36
|
+
* used to call `sendChunked(c, entry.text)` and drop both, so a scheduled
|
|
37
|
+
* message with buttons arrived bare while the caller had been told ok:true.
|
|
38
|
+
*/
|
|
39
|
+
async function fireScheduled(
|
|
40
|
+
channel: Awaited<ReturnType<typeof resolveChannel>>,
|
|
41
|
+
entry: ScheduledMessage,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!channel) return;
|
|
44
|
+
const replyTo =
|
|
45
|
+
entry.replyTo !== undefined ? String(entry.replyTo) : undefined;
|
|
46
|
+
if (!entry.rows || entry.rows.length === 0) {
|
|
47
|
+
await sendChunked(channel, entry.text, replyTo);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const components = buildButtonRows(entry.rows);
|
|
51
|
+
const chunks = splitMessage(suppressMentions(entry.text), DISCORD_MAX_TEXT);
|
|
52
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
53
|
+
if (!channel.isSendable()) return;
|
|
54
|
+
const last = i === chunks.length - 1;
|
|
55
|
+
await channel.send({
|
|
56
|
+
content: chunks[i],
|
|
57
|
+
...(last ? { components } : {}),
|
|
58
|
+
allowedMentions: { parse: [] },
|
|
59
|
+
...(i === 0 && replyTo
|
|
60
|
+
? { reply: { messageReference: replyTo, failIfNotExists: false } }
|
|
61
|
+
: {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
/** Arm a timer for a stored entry; fires, then cleans up store + map. */
|
|
28
67
|
function armScheduled(
|
|
29
68
|
client: Client,
|
|
@@ -34,7 +73,7 @@ function armScheduled(
|
|
|
34
73
|
const timer = setTimeout(async () => {
|
|
35
74
|
try {
|
|
36
75
|
const c = await resolveChannel(client, Number(entry.chatId));
|
|
37
|
-
if (c) await
|
|
76
|
+
if (c) await fireScheduled(c, entry);
|
|
38
77
|
} catch (err) {
|
|
39
78
|
logError(
|
|
40
79
|
"discord",
|
|
@@ -189,25 +228,44 @@ export const messagingHandlers: DiscordActionHandlers = {
|
|
|
189
228
|
}>
|
|
190
229
|
>;
|
|
191
230
|
gateway.incrementMessages(chatId);
|
|
192
|
-
const components = buildButtonRows(rows);
|
|
193
|
-
const safe = suppressMentions(text).slice(0, DISCORD_MAX_TEXT);
|
|
194
231
|
if (!channel!.isSendable())
|
|
195
232
|
return { ok: false, error: "Channel not sendable" };
|
|
196
233
|
return tryAction("send_message_with_buttons", async () => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
234
|
+
// buildButtonRows is inside tryAction on purpose: it can still reject a
|
|
235
|
+
// row, and escaping here would bypass the action's error mapping and
|
|
236
|
+
// lose the text as well as the buttons.
|
|
237
|
+
const components = buildButtonRows(rows);
|
|
238
|
+
// Chunk rather than slice. `.slice(0, 2000)` dropped everything past
|
|
239
|
+
// the limit mid-word and still answered ok:true, so the model never
|
|
240
|
+
// learned its reply had been cut — while the SAME text without buttons
|
|
241
|
+
// went through sendChunked and arrived whole.
|
|
242
|
+
const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
|
|
243
|
+
const ids: string[] = [];
|
|
244
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
245
|
+
const last = i === chunks.length - 1;
|
|
246
|
+
const sent = await withRetry(
|
|
247
|
+
() =>
|
|
248
|
+
channel!.send({
|
|
249
|
+
content: chunks[i],
|
|
250
|
+
// Buttons belong on the final chunk: they act on the whole
|
|
251
|
+
// message, and Discord would render a set per chunk otherwise.
|
|
252
|
+
...(last ? { components } : {}),
|
|
253
|
+
allowedMentions: { parse: [] },
|
|
254
|
+
}) as Promise<{ id: string }>,
|
|
255
|
+
);
|
|
256
|
+
ids.push(sent.id);
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, message_id: ids[0], message_ids: ids };
|
|
206
259
|
});
|
|
207
260
|
},
|
|
208
261
|
|
|
209
262
|
schedule_message: (body, chatId, { client, scheduledMessages }) => {
|
|
210
263
|
const text = String(body.text ?? "");
|
|
264
|
+
const rows = body.rows as ScheduledMessage["rows"];
|
|
265
|
+
const replyTo =
|
|
266
|
+
typeof body.reply_to_message_id === "string"
|
|
267
|
+
? body.reply_to_message_id
|
|
268
|
+
: undefined;
|
|
211
269
|
// NaN (e.g. delay_seconds: "5m") must fall back to the default, not
|
|
212
270
|
// propagate: setTimeout(fn, NaN) fires immediately.
|
|
213
271
|
const requested = Number(body.delay_seconds ?? 60);
|
|
@@ -222,6 +280,8 @@ export const messagingHandlers: DiscordActionHandlers = {
|
|
|
222
280
|
text,
|
|
223
281
|
fireAt: Date.now() + delaySec * 1000,
|
|
224
282
|
createdAt: Date.now(),
|
|
283
|
+
replyTo,
|
|
284
|
+
rows,
|
|
225
285
|
};
|
|
226
286
|
// Persist before arming: if we crash between the two, the restore
|
|
227
287
|
// path delivers it; the reverse order could lose it forever.
|
|
@@ -70,11 +70,21 @@ export function buildButtonRows(
|
|
|
70
70
|
>,
|
|
71
71
|
): ActionRowBuilder<ButtonBuilder>[] {
|
|
72
72
|
const out: ActionRowBuilder<ButtonBuilder>[] = [];
|
|
73
|
+
// custom_id must be unique within a message or Discord rejects the send.
|
|
74
|
+
// Two buttons with the same label — or none at all, which both collapse to
|
|
75
|
+
// the bare `ai:` prefix — used to produce a duplicate pair.
|
|
76
|
+
const usedIds = new Set<string>();
|
|
73
77
|
for (const row of rows.slice(0, 5)) {
|
|
74
78
|
const arb = new ActionRowBuilder<ButtonBuilder>();
|
|
75
79
|
for (const btn of row.slice(0, 5)) {
|
|
76
80
|
const b = new ButtonBuilder().setLabel(safeSlice(btn.text || "•", 80));
|
|
77
|
-
|
|
81
|
+
// Only http(s) can be a Link button. discord.js throws on anything
|
|
82
|
+
// else — a bare domain ("docs.example.com", a common model output) or
|
|
83
|
+
// a javascript: URL — and the throw escapes before the send, so ONE
|
|
84
|
+
// malformed URL discarded the reply text and every other button too.
|
|
85
|
+
// Downgrade to a plain non-link button instead: the label still says
|
|
86
|
+
// what it meant, and the rest of the message survives.
|
|
87
|
+
if (btn.url !== undefined && /^https?:\/\//i.test(btn.url)) {
|
|
78
88
|
b.setStyle(ButtonStyle.Link).setURL(btn.url);
|
|
79
89
|
} else {
|
|
80
90
|
const styleMap: Record<string, ButtonStyle> = {
|
|
@@ -88,13 +98,21 @@ export function buildButtonRows(
|
|
|
88
98
|
// Namespace AI-generated custom_ids under `ai:` so the callback router
|
|
89
99
|
// never confuses them with system custom_ids like `settings:done`.
|
|
90
100
|
// The router strips the prefix before forwarding to the agent.
|
|
101
|
+
//
|
|
102
|
+
// Budget is 100 UTF-16 units total (Discord's cap), so the raw part
|
|
103
|
+
// gets 100 minus the 3-unit prefix minus room for a de-dupe suffix.
|
|
91
104
|
const raw = btn.callback_data || btn.text || "";
|
|
92
|
-
|
|
105
|
+
let id = `ai:${safeSlice(raw, 93)}`;
|
|
106
|
+
for (let n = 2; usedIds.has(id); n++)
|
|
107
|
+
id = `ai:${safeSlice(raw, 93)}#${n}`;
|
|
108
|
+
usedIds.add(id);
|
|
93
109
|
b.setStyle(style).setCustomId(id);
|
|
94
110
|
}
|
|
95
111
|
arb.addComponents(b);
|
|
96
112
|
}
|
|
97
|
-
|
|
113
|
+
// Discord requires at least one component per action row; an empty row
|
|
114
|
+
// (from an empty inner array) fails the whole message.
|
|
115
|
+
if (arb.components.length > 0) out.push(arb);
|
|
98
116
|
}
|
|
99
117
|
return out;
|
|
100
118
|
}
|
|
@@ -9,7 +9,7 @@ import type { Gateway } from "../../../core/engine/gateway.js";
|
|
|
9
9
|
import { respawnSelf } from "../../../util/respawn.js";
|
|
10
10
|
import { forceDream } from "../../../core/background/dream.js";
|
|
11
11
|
import { formatDuration, renderMetricsMessages } from "../helpers.js";
|
|
12
|
-
import { getMetrics, getTodayMetrics } from "../../../
|
|
12
|
+
import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
|
|
13
13
|
import { handleAdminSubcommand } from "../admin.js";
|
|
14
14
|
import { isAdmin } from "../handlers/index.js";
|
|
15
15
|
import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
|
|
@@ -18,13 +18,24 @@ type DiscordAPIErrorLike = {
|
|
|
18
18
|
rawError?: { code?: number };
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Extract numeric JSON error code, if this looks like a DiscordAPIError.
|
|
23
|
+
*
|
|
24
|
+
* Walks the `cause` chain, which is what makes this work at all on the
|
|
25
|
+
* primary send paths. Those run inside `withRetry`, which rethrows
|
|
26
|
+
* `classify(err)` — a TalonError carrying only the message, with the
|
|
27
|
+
* DiscordAPIError demoted to `cause`. A shallow look therefore found no
|
|
28
|
+
* code and every curated message below was dead on `send_message`,
|
|
29
|
+
* `reply_to`, `send_message_with_buttons`, the media sends, and
|
|
30
|
+
* forward/copy: the agent got a bare "Missing Permissions" instead of
|
|
31
|
+
* being told which permission and on what.
|
|
32
|
+
*/
|
|
33
|
+
function discordErrorCode(err: unknown, depth = 0): number | null {
|
|
34
|
+
if (depth > 5 || !err || typeof err !== "object") return null;
|
|
35
|
+
const e = err as DiscordAPIErrorLike & { cause?: unknown };
|
|
25
36
|
if (typeof e.code === "number") return e.code;
|
|
26
37
|
if (typeof e.rawError?.code === "number") return e.rawError.code;
|
|
27
|
-
return
|
|
38
|
+
return discordErrorCode(e.cause, depth + 1);
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
/**
|
|
@@ -58,12 +58,27 @@ export function suppressMentions(text: string): string {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Slice a string
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* Slice a string so it satisfies a Discord length limit, without splitting a
|
|
62
|
+
* surrogate pair. Used for `customId`, `label`, `value`, etc.
|
|
63
|
+
*
|
|
64
|
+
* `max` counts UTF-16 code units, because that is what Discord (and
|
|
65
|
+
* discord.js's validators, which call `String.length`) measure. Counting
|
|
66
|
+
* code points instead — `Array.from(text).slice(0, max)` — silently
|
|
67
|
+
* overshoots for any astral character: 80 code points of emoji is 160 code
|
|
68
|
+
* units, so the guard passed and discord.js then threw
|
|
69
|
+
* `ExpectedConstraintError: Invalid string length`, taking the whole message
|
|
70
|
+
* with it. The guard worked precisely when it wasn't needed and failed when
|
|
71
|
+
* it was.
|
|
64
72
|
*/
|
|
65
73
|
export function safeSlice(text: string, max: number): string {
|
|
66
|
-
|
|
74
|
+
if (text.length <= max) return text;
|
|
75
|
+
let out = "";
|
|
76
|
+
// Iterating a string yields whole code points, so a pair is never split.
|
|
77
|
+
for (const char of text) {
|
|
78
|
+
if (out.length + char.length > max) break;
|
|
79
|
+
out += char;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/** Simple HTML-style escape used when displaying raw text inside code blocks. */
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
} from "../../core/engine/backend-controller/index.js";
|
|
70
70
|
import { getActiveReasoningLevels } from "../shared/reasoning-levels.js";
|
|
71
71
|
import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
|
|
72
|
-
import { extractSessionName } from "../../
|
|
72
|
+
import { extractSessionName } from "../../util/session-name.js";
|
|
73
73
|
import { BridgeServer, type BridgeServerHandlers } from "./server.js";
|
|
74
74
|
import { createNativeActionHandler } from "./actions.js";
|
|
75
75
|
import { getMeshService } from "../../core/mesh/index.js";
|
|
@@ -1192,8 +1192,10 @@ export function createNativeFrontend(
|
|
|
1192
1192
|
storeLocation: (body) => mesh.storeLocation(body),
|
|
1193
1193
|
listDevices: () => mesh.list(),
|
|
1194
1194
|
completeCommand: (body) => mesh.completeCommand(body),
|
|
1195
|
-
acceptFileUpload: (token, body) =>
|
|
1196
|
-
|
|
1195
|
+
acceptFileUpload: (token, body, fromDeviceId) =>
|
|
1196
|
+
mesh.acceptFileUpload(token, body, fromDeviceId),
|
|
1197
|
+
openFileDownload: (token, fromDeviceId) =>
|
|
1198
|
+
mesh.openFileDownload(token, fromDeviceId),
|
|
1197
1199
|
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
1198
1200
|
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
1199
1201
|
};
|
|
@@ -1205,14 +1207,22 @@ export function createNativeFrontend(
|
|
|
1205
1207
|
const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
|
|
1206
1208
|
// Never serve the agent API to the network unauthenticated: a non-loopback
|
|
1207
1209
|
// bind with no configured token gets a persistent auto-minted one instead.
|
|
1210
|
+
// `?? ` alone would treat an empty string as a configured token and skip
|
|
1211
|
+
// the mint, serving the LAN unauthenticated while looking configured —
|
|
1212
|
+
// easy to hit from `"token": "${BRIDGE_TOKEN}"` with the var unset.
|
|
1213
|
+
const configuredToken =
|
|
1214
|
+
nativeCfg.token !== undefined && nativeCfg.token !== ""
|
|
1215
|
+
? nativeCfg.token
|
|
1216
|
+
: undefined;
|
|
1208
1217
|
const bridgeToken =
|
|
1209
|
-
|
|
1218
|
+
configuredToken ??
|
|
1210
1219
|
(isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
|
|
1211
1220
|
const server = new BridgeServer(
|
|
1212
1221
|
{
|
|
1213
1222
|
host: bridgeHost,
|
|
1214
1223
|
port: nativeCfg.port ?? 19880,
|
|
1215
1224
|
token: bridgeToken,
|
|
1225
|
+
allowedOrigins: nativeCfg.allowedOrigins,
|
|
1216
1226
|
startedAt,
|
|
1217
1227
|
...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
|
|
1218
1228
|
},
|
|
@@ -1249,13 +1259,17 @@ export function createNativeFrontend(
|
|
|
1249
1259
|
async init() {
|
|
1250
1260
|
await mesh.load();
|
|
1251
1261
|
// Plug this bridge in as the mesh's transport: locates and device
|
|
1252
|
-
// commands (from ANY frontend's mesh tool calls)
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1262
|
+
// commands (from ANY frontend's mesh tool calls) leave as SSE events.
|
|
1263
|
+
//
|
|
1264
|
+
// A locate is a bare "who's there?" — no secret in the frame, and
|
|
1265
|
+
// pre-command app builds rely on receiving it — so it still fans out.
|
|
1266
|
+
// A command is the opposite: its params carry transfer tokens, exec
|
|
1267
|
+
// command lines and (on the chunked fallback) file bodies, so it goes
|
|
1268
|
+
// to the target device's own client(s) only.
|
|
1255
1269
|
unregisterMeshTransport = mesh.registerTransport({
|
|
1256
1270
|
locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
|
|
1257
1271
|
command: (command) =>
|
|
1258
|
-
|
|
1272
|
+
server.sendToDevice(command.deviceId, {
|
|
1259
1273
|
kind: "device_command",
|
|
1260
1274
|
id: command.id,
|
|
1261
1275
|
deviceId: command.deviceId,
|
|
@@ -286,6 +286,12 @@ export type BridgeEvent =
|
|
|
286
286
|
* target device executes it and answers via POST /devices/command-result
|
|
287
287
|
* with the same `id` as `commandId`. Additive in v1 — app builds that
|
|
288
288
|
* predate the command channel simply ignore the event.
|
|
289
|
+
*
|
|
290
|
+
* Addressed, not broadcast: params carry one-time transfer tokens, exec
|
|
291
|
+
* command lines and (on the chunked fallback) file bodies, so the frame
|
|
292
|
+
* goes only to the SSE client(s) that claimed this `deviceId` via
|
|
293
|
+
* `GET /events?deviceId=…`. Clients that claim no id still receive
|
|
294
|
+
* commands for devices nobody claimed, so pre-claim builds keep working.
|
|
289
295
|
*/
|
|
290
296
|
| {
|
|
291
297
|
kind: "device_command";
|
|
@@ -133,14 +133,17 @@ export type BridgeServerHandlers = {
|
|
|
133
133
|
| Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
|
|
134
134
|
/** A device answered a device_command; true when a call was waiting. */
|
|
135
135
|
completeCommand(body: Record<string, unknown>): boolean;
|
|
136
|
-
/** A device streams a pull-transfer's file body up (raw request body).
|
|
136
|
+
/** A device streams a pull-transfer's file body up (raw request body).
|
|
137
|
+
* `fromDeviceId` is the caller's claimed identity, when it sent one. */
|
|
137
138
|
acceptFileUpload(
|
|
138
139
|
token: string,
|
|
139
140
|
body: IncomingMessage,
|
|
141
|
+
fromDeviceId?: string,
|
|
140
142
|
): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
|
|
141
143
|
/** Resolve a push-transfer token to the file to stream down, or null. */
|
|
142
144
|
openFileDownload(
|
|
143
145
|
token: string,
|
|
146
|
+
fromDeviceId?: string,
|
|
144
147
|
): Promise<{ path: string; size: number } | null>;
|
|
145
148
|
/** Resolve a node-provisioning token to its installer script, or null. */
|
|
146
149
|
openNodeInstall(token: string): { script: string; filename: string } | null;
|
|
@@ -152,6 +155,12 @@ const SSE_PING_MS = 25_000;
|
|
|
152
155
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
153
156
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
154
157
|
const PORT_FALLBACKS = 5;
|
|
158
|
+
/**
|
|
159
|
+
* Longest device id a client may claim (`?deviceId=…`). Matches the
|
|
160
|
+
* registry's own id cap — a longer id can never name a real device, and the
|
|
161
|
+
* claim is held for the life of a connection, so it stays a bounded key.
|
|
162
|
+
*/
|
|
163
|
+
const MAX_DEVICE_ID_CHARS = 128;
|
|
155
164
|
|
|
156
165
|
// Failed-auth lockout: after this many wrong tokens from one address inside
|
|
157
166
|
// the window, that address gets 429s until the window lapses. The token's
|
|
@@ -173,7 +182,13 @@ type AuthState = "ok" | "anonymous" | "bad";
|
|
|
173
182
|
|
|
174
183
|
export class BridgeServer {
|
|
175
184
|
private server: Server | null = null;
|
|
176
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Live SSE connections → the mesh device id each one claimed on connect
|
|
187
|
+
* (undefined for clients that didn't claim one: desktop UIs, and companion
|
|
188
|
+
* builds from before the claim existed). The claim is what makes
|
|
189
|
+
* `sendToDevice` addressable rather than a shout.
|
|
190
|
+
*/
|
|
191
|
+
private clients = new Map<ServerResponse, string | undefined>();
|
|
177
192
|
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
178
193
|
private port = 0;
|
|
179
194
|
private tlsIdentity: BridgeTlsIdentity | null = null;
|
|
@@ -185,6 +200,9 @@ export class BridgeServer {
|
|
|
185
200
|
host: string;
|
|
186
201
|
port: number;
|
|
187
202
|
token?: string;
|
|
203
|
+
/** Origins permitted to call the bridge from a browser. Empty by
|
|
204
|
+
* default: native clients send no Origin and need no entry here. */
|
|
205
|
+
allowedOrigins?: readonly string[];
|
|
188
206
|
startedAt: string;
|
|
189
207
|
/**
|
|
190
208
|
* When present, the bridge serves HTTPS with this identity. A provider
|
|
@@ -213,8 +231,50 @@ export class BridgeServer {
|
|
|
213
231
|
/** Push an event to every connected SSE client. */
|
|
214
232
|
broadcast(event: BridgeEvent): void {
|
|
215
233
|
if (this.clients.size === 0) return;
|
|
234
|
+
this.write(this.clients.keys(), event);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Push an event to the client(s) that claimed `deviceId` — the delivery
|
|
239
|
+
* path for anything addressed to ONE device.
|
|
240
|
+
*
|
|
241
|
+
* Device commands are not public: their params carry one-time transfer
|
|
242
|
+
* tokens, exec command lines, remote paths, and — on the chunked fallback —
|
|
243
|
+
* whole base64 file bodies. Broadcasting them handed every connected client
|
|
244
|
+
* another device's secrets and relied on each client discarding what wasn't
|
|
245
|
+
* addressed to it, which is courtesy, not enforcement.
|
|
246
|
+
*
|
|
247
|
+
* A claim is an ADDRESS, not a credential: any client holding the bridge
|
|
248
|
+
* token could claim any id, and the bridge token is (still) the only trust
|
|
249
|
+
* boundary here. What this buys is that a device no longer passively
|
|
250
|
+
* receives traffic meant for its peers.
|
|
251
|
+
*
|
|
252
|
+
* Clients that claimed nothing are the fallback audience, and only when the
|
|
253
|
+
* target claimed nothing either: a companion build that predates the claim
|
|
254
|
+
* can't be addressed, and dropping its commands would take the mesh offline
|
|
255
|
+
* for it. So an updated device's traffic never reaches them — the fallback
|
|
256
|
+
* shrinks to nothing as the fleet updates.
|
|
257
|
+
*/
|
|
258
|
+
sendToDevice(deviceId: string, event: BridgeEvent): void {
|
|
259
|
+
if (this.clients.size === 0) return;
|
|
260
|
+
const claimed: ServerResponse[] = [];
|
|
261
|
+
const unclaimed: ServerResponse[] = [];
|
|
262
|
+
for (const [res, id] of this.clients) {
|
|
263
|
+
if (id === deviceId) claimed.push(res);
|
|
264
|
+
else if (id === undefined) unclaimed.push(res);
|
|
265
|
+
}
|
|
266
|
+
if (claimed.length === 0) {
|
|
267
|
+
logDebug(
|
|
268
|
+
"native",
|
|
269
|
+
`No SSE client claims device ${deviceId} — delivering to ${unclaimed.length} unclaimed client(s)`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
this.write(claimed.length > 0 ? claimed : unclaimed, event);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private write(targets: Iterable<ServerResponse>, event: BridgeEvent): void {
|
|
216
276
|
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
217
|
-
for (const res of
|
|
277
|
+
for (const res of targets) {
|
|
218
278
|
try {
|
|
219
279
|
res.write(payload);
|
|
220
280
|
} catch {
|
|
@@ -245,7 +305,7 @@ export class BridgeServer {
|
|
|
245
305
|
: createServer(onRequest);
|
|
246
306
|
|
|
247
307
|
this.pingTimer = setInterval(() => {
|
|
248
|
-
for (const res of this.clients) {
|
|
308
|
+
for (const res of this.clients.keys()) {
|
|
249
309
|
try {
|
|
250
310
|
res.write(": ping\n\n");
|
|
251
311
|
} catch {
|
|
@@ -301,7 +361,7 @@ export class BridgeServer {
|
|
|
301
361
|
|
|
302
362
|
async stop(): Promise<void> {
|
|
303
363
|
clearInterval(this.pingTimer);
|
|
304
|
-
for (const res of this.clients) {
|
|
364
|
+
for (const res of this.clients.keys()) {
|
|
305
365
|
try {
|
|
306
366
|
res.end();
|
|
307
367
|
} catch {
|
|
@@ -329,6 +389,29 @@ export class BridgeServer {
|
|
|
329
389
|
const path = url.pathname;
|
|
330
390
|
const method = req.method ?? "GET";
|
|
331
391
|
|
|
392
|
+
// Origin / Host guard runs before everything, including OPTIONS: a
|
|
393
|
+
// preflight that answers 204 to any origin is itself the permission
|
|
394
|
+
// slip the browser is asking for.
|
|
395
|
+
const origin =
|
|
396
|
+
typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
397
|
+
const refusal = this.originGuard(req);
|
|
398
|
+
if (refusal !== undefined) {
|
|
399
|
+
res.writeHead(403, {
|
|
400
|
+
...this.corsHeaders(),
|
|
401
|
+
"Content-Type": "application/json",
|
|
402
|
+
});
|
|
403
|
+
res.end(JSON.stringify({ ok: false, error: refusal }));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Set once here rather than in corsHeaders(): setHeader values survive
|
|
407
|
+
// every later writeHead(code, {...}) that does not name the same key,
|
|
408
|
+
// so each of the ~8 response sites keeps the grant without threading
|
|
409
|
+
// the origin through all of them.
|
|
410
|
+
if (origin !== undefined && this.isAllowedOrigin(origin)) {
|
|
411
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
412
|
+
res.setHeader("Vary", "Origin");
|
|
413
|
+
}
|
|
414
|
+
|
|
332
415
|
if (method === "OPTIONS") {
|
|
333
416
|
res.writeHead(204, this.corsHeaders());
|
|
334
417
|
res.end();
|
|
@@ -425,7 +508,10 @@ export class BridgeServer {
|
|
|
425
508
|
}
|
|
426
509
|
|
|
427
510
|
try {
|
|
428
|
-
|
|
511
|
+
// A mesh client names itself here so device-addressed events reach it
|
|
512
|
+
// alone (see sendToDevice); UI clients simply omit it.
|
|
513
|
+
if (method === "GET" && path === "/events")
|
|
514
|
+
return this.openStream(res, deviceIdParam(url));
|
|
429
515
|
|
|
430
516
|
if (method === "GET" && path === "/chats")
|
|
431
517
|
return this.json(res, 200, { chats: this.handlers.listChats() });
|
|
@@ -549,12 +635,15 @@ export class BridgeServer {
|
|
|
549
635
|
const token = url.searchParams.get("transfer") ?? "";
|
|
550
636
|
if (!token)
|
|
551
637
|
return this.json(res, 400, { ok: false, error: "transfer required" });
|
|
638
|
+
// The caller names itself so the token's device binding can be
|
|
639
|
+
// checked (see core/mesh/transfers.ts take()).
|
|
640
|
+
const from = deviceIdParam(url);
|
|
552
641
|
if (method === "POST") {
|
|
553
|
-
const result = await this.handlers.acceptFileUpload(token, req);
|
|
642
|
+
const result = await this.handlers.acceptFileUpload(token, req, from);
|
|
554
643
|
return this.json(res, result.ok ? 200 : 409, result);
|
|
555
644
|
}
|
|
556
645
|
if (method === "GET") {
|
|
557
|
-
const file = await this.handlers.openFileDownload(token);
|
|
646
|
+
const file = await this.handlers.openFileDownload(token, from);
|
|
558
647
|
if (!file)
|
|
559
648
|
return this.json(res, 404, {
|
|
560
649
|
ok: false,
|
|
@@ -725,7 +814,7 @@ export class BridgeServer {
|
|
|
725
814
|
}
|
|
726
815
|
}
|
|
727
816
|
|
|
728
|
-
private openStream(res: ServerResponse): void {
|
|
817
|
+
private openStream(res: ServerResponse, deviceId?: string): void {
|
|
729
818
|
res.writeHead(200, {
|
|
730
819
|
...this.corsHeaders(),
|
|
731
820
|
"Content-Type": "text/event-stream",
|
|
@@ -752,8 +841,11 @@ export class BridgeServer {
|
|
|
752
841
|
} catch (err) {
|
|
753
842
|
logError("native", "Failed to replay live turn to new client", err);
|
|
754
843
|
}
|
|
755
|
-
this.clients.
|
|
756
|
-
logDebug(
|
|
844
|
+
this.clients.set(res, deviceId);
|
|
845
|
+
logDebug(
|
|
846
|
+
"native",
|
|
847
|
+
`SSE client connected${deviceId ? ` as device ${deviceId}` : ""} (${this.clients.size} total)`,
|
|
848
|
+
);
|
|
757
849
|
res.on("close", () => {
|
|
758
850
|
this.clients.delete(res);
|
|
759
851
|
logDebug("native", `SSE client left (${this.clients.size} total)`);
|
|
@@ -824,9 +916,17 @@ export class BridgeServer {
|
|
|
824
916
|
);
|
|
825
917
|
}
|
|
826
918
|
|
|
919
|
+
/**
|
|
920
|
+
* CORS headers.
|
|
921
|
+
*
|
|
922
|
+
* Deliberately NOT `Access-Control-Allow-Origin: *`. The bridge's clients
|
|
923
|
+
* are native apps (Electron main process, Flutter, curl, talon-node),
|
|
924
|
+
* which send no `Origin` at all — a wildcard buys them nothing and hands
|
|
925
|
+
* every web page on the internet a readable cross-origin channel to the
|
|
926
|
+
* agent API. Only an explicitly configured origin is echoed back.
|
|
927
|
+
*/
|
|
827
928
|
private corsHeaders(): Record<string, string> {
|
|
828
929
|
return {
|
|
829
|
-
"Access-Control-Allow-Origin": "*",
|
|
830
930
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
831
931
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
832
932
|
"Access-Control-Max-Age": "86400",
|
|
@@ -835,6 +935,55 @@ export class BridgeServer {
|
|
|
835
935
|
};
|
|
836
936
|
}
|
|
837
937
|
|
|
938
|
+
/** True when `origin` is on the operator's `native.allowedOrigins` list. */
|
|
939
|
+
private isAllowedOrigin(origin: string): boolean {
|
|
940
|
+
return this.opts.allowedOrigins?.includes(origin) ?? false;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Reject browser-driven cross-origin requests and DNS-rebinding.
|
|
945
|
+
*
|
|
946
|
+
* Two independent checks, because they stop different attacks:
|
|
947
|
+
*
|
|
948
|
+
* - `Origin`: browsers attach it to every cross-origin request and
|
|
949
|
+
* scripts cannot forge it. Native clients omit it entirely. So "an
|
|
950
|
+
* Origin we did not allow" means "a web page is driving us" — which,
|
|
951
|
+
* on the default unauthenticated loopback bind, would let any site
|
|
952
|
+
* the user visits POST /send and run tools on this machine.
|
|
953
|
+
* - `Host`: a name that resolves to 127.0.0.1 makes the request
|
|
954
|
+
* SAME-origin, so no Origin header is sent and the check above never
|
|
955
|
+
* fires. Pinning Host to loopback/the configured bind closes that.
|
|
956
|
+
*
|
|
957
|
+
* Returns an error string when the request must be refused.
|
|
958
|
+
*/
|
|
959
|
+
private originGuard(req: IncomingMessage): string | undefined {
|
|
960
|
+
const origin = req.headers.origin;
|
|
961
|
+
if (typeof origin === "string" && origin !== "" && origin !== "null") {
|
|
962
|
+
if (!this.isAllowedOrigin(origin)) {
|
|
963
|
+
return `Origin ${origin} is not allowed. Add it to native.allowedOrigins to permit browser clients.`;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const host = req.headers.host;
|
|
968
|
+
if (typeof host === "string" && host !== "") {
|
|
969
|
+
// Strip the port; bracketed IPv6 keeps its brackets off.
|
|
970
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
971
|
+
const allowed =
|
|
972
|
+
name === "127.0.0.1" ||
|
|
973
|
+
name === "localhost" ||
|
|
974
|
+
name === "::1" ||
|
|
975
|
+
name === this.opts.host ||
|
|
976
|
+
// A wildcard bind is reachable under every local name; the bearer
|
|
977
|
+
// token is the control there, not the Host header.
|
|
978
|
+
this.opts.host === "0.0.0.0" ||
|
|
979
|
+
this.opts.host === "::";
|
|
980
|
+
if (!allowed) {
|
|
981
|
+
return `Host ${host} is not recognised for this bridge (DNS-rebinding guard).`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
838
987
|
private jsonHeaders(): Record<string, string> {
|
|
839
988
|
return { ...this.corsHeaders(), "Content-Type": "application/json" };
|
|
840
989
|
}
|
|
@@ -880,6 +1029,16 @@ function asString(v: unknown): string | undefined {
|
|
|
880
1029
|
return typeof v === "string" ? v : undefined;
|
|
881
1030
|
}
|
|
882
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* The `deviceId` a mesh client claims on `/events` and `/devices/file`.
|
|
1034
|
+
* Undefined when absent or blank — every consumer treats "no claim" as the
|
|
1035
|
+
* legacy case, so an empty string must never look like a claimed id.
|
|
1036
|
+
*/
|
|
1037
|
+
function deviceIdParam(url: URL): string | undefined {
|
|
1038
|
+
const raw = (url.searchParams.get("deviceId") ?? "").trim();
|
|
1039
|
+
return raw ? raw.slice(0, MAX_DEVICE_ID_CHARS) : undefined;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
883
1042
|
/** Parse a positive-integer query param; undefined when absent/invalid. */
|
|
884
1043
|
function asPositiveInt(v: string | null): number | undefined {
|
|
885
1044
|
if (!v) return undefined;
|
|
@@ -38,6 +38,12 @@ export function formatTokenCount(n: number): string {
|
|
|
38
38
|
return String(n);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Keep small model costs legible without making larger totals noisy. */
|
|
42
|
+
export function formatUsd(cost: number): string {
|
|
43
|
+
const safeCost = Number.isFinite(cost) && cost > 0 ? cost : 0;
|
|
44
|
+
return `$${safeCost > 0.5 ? safeCost.toFixed(2) : safeCost.toFixed(4)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
export function formatBytes(bytes: number): string {
|
|
42
48
|
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
|
43
49
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|