talon-agent 3.33.0 → 3.33.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/package.json +3 -3
- package/src/backend/kilo/factory.ts +39 -119
- package/src/backend/kilo/handler/index.ts +1 -11
- package/src/backend/kilo/handler/message.ts +20 -317
- package/src/backend/kilo/server.ts +55 -272
- package/src/backend/kilo/sessions.ts +5 -83
- package/src/backend/opencode/factory.ts +41 -118
- package/src/backend/opencode/handler/index.ts +1 -11
- package/src/backend/opencode/handler/message.ts +20 -321
- package/src/backend/opencode/server.ts +57 -217
- package/src/backend/opencode/sessions.ts +5 -83
- package/src/backend/remote-server/chat-turn.ts +359 -0
- package/src/backend/remote-server/factory.ts +150 -0
- package/src/backend/remote-server/index.ts +14 -19
- package/src/backend/remote-server/server-bindings.ts +232 -0
- package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
- package/src/core/mesh/bridge-links.ts +319 -0
- package/src/core/mesh/common.ts +30 -0
- package/src/core/mesh/device-files.ts +710 -0
- package/src/core/mesh/service.ts +72 -894
- package/src/frontend/discord/handlers/access.ts +7 -56
- package/src/frontend/discord/handlers/state.ts +15 -8
- package/src/frontend/native/server.ts +391 -323
- package/src/frontend/shared/access.ts +152 -0
- package/src/frontend/telegram/handlers/access.ts +8 -25
- package/src/frontend/telegram/handlers/queue.ts +3 -35
- package/src/frontend/telegram/handlers/state.ts +15 -8
- package/src/backend/kilo/events.ts +0 -60
- package/src/backend/kilo/handler/state.ts +0 -9
- package/src/backend/opencode/handler/state.ts +0 -9
- package/src/backend/opencode/handler/turn.ts +0 -314
|
@@ -185,6 +185,87 @@ const AUTH_LOCKOUT_MAX_TRACKED = 10_000;
|
|
|
185
185
|
*/
|
|
186
186
|
type AuthState = "ok" | "anonymous" | "bad";
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* "public": served without a bearer token. Every entry is gated some other
|
|
190
|
+
* way — a single-use grant minted by the daemon, or (for /health) by
|
|
191
|
+
* answering only what pairing needs until a token is presented.
|
|
192
|
+
* "bearer": the request must carry the bridge token.
|
|
193
|
+
*/
|
|
194
|
+
export type BridgeRouteAuth = "public" | "bearer";
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The bridge's routes and the auth tier of each — declared once, here,
|
|
198
|
+
* rather than implied by where an `if` sits relative to the auth check.
|
|
199
|
+
* The security posture of the transport is this table: a route is
|
|
200
|
+
* pre-auth only by appearing in it as "public", and the route test walks
|
|
201
|
+
* every entry and proves the tier holds on the wire. Adding a route
|
|
202
|
+
* without an entry is a type error (`buildRoutes` is exhaustive over
|
|
203
|
+
* these keys); adding one as "public" is a diff a reviewer sees.
|
|
204
|
+
*/
|
|
205
|
+
export const BRIDGE_ROUTE_AUTH = {
|
|
206
|
+
// Pre-auth by design. /health serves pairing data (identity, protocol,
|
|
207
|
+
// fingerprint) to anyone and the operational view only to a token
|
|
208
|
+
// holder. /pair, /node/install and /node/binary hand over a credential
|
|
209
|
+
// to a device that holds none yet; the single-use grant in the query is
|
|
210
|
+
// the entire authorization.
|
|
211
|
+
"GET /health": "public",
|
|
212
|
+
"GET /pair": "public",
|
|
213
|
+
"GET /node/install": "public",
|
|
214
|
+
"GET /node/binary": "public",
|
|
215
|
+
|
|
216
|
+
// Everything a client can do once paired.
|
|
217
|
+
"GET /events": "bearer",
|
|
218
|
+
"GET /chats": "bearer",
|
|
219
|
+
"POST /chats": "bearer",
|
|
220
|
+
"POST /chats/rename": "bearer",
|
|
221
|
+
"POST /chats/delete": "bearer",
|
|
222
|
+
"POST /chats/reset": "bearer",
|
|
223
|
+
"POST /chats/interrupt": "bearer",
|
|
224
|
+
"POST /chats/pulse": "bearer",
|
|
225
|
+
"POST /queue": "bearer",
|
|
226
|
+
"GET /history": "bearer",
|
|
227
|
+
"GET /search": "bearer",
|
|
228
|
+
"POST /send": "bearer",
|
|
229
|
+
"POST /upload": "bearer",
|
|
230
|
+
"GET /media": "bearer",
|
|
231
|
+
"GET /models": "bearer",
|
|
232
|
+
"POST /model": "bearer",
|
|
233
|
+
"GET /backends": "bearer",
|
|
234
|
+
"POST /backend": "bearer",
|
|
235
|
+
"GET /effort": "bearer",
|
|
236
|
+
"POST /effort": "bearer",
|
|
237
|
+
"GET /logs": "bearer",
|
|
238
|
+
"GET /plugins": "bearer",
|
|
239
|
+
"POST /plugins/toggle": "bearer",
|
|
240
|
+
"GET /skills": "bearer",
|
|
241
|
+
"POST /skills/toggle": "bearer",
|
|
242
|
+
"GET /config": "bearer",
|
|
243
|
+
"POST /config": "bearer",
|
|
244
|
+
"POST /control": "bearer",
|
|
245
|
+
|
|
246
|
+
// Mesh. The one-time `transfer` token on /devices/file authorizes one
|
|
247
|
+
// direction+path, but the route still sits behind the bearer like every
|
|
248
|
+
// device route — the token is a scope, not a credential.
|
|
249
|
+
"POST /devices/register": "bearer",
|
|
250
|
+
"POST /location": "bearer",
|
|
251
|
+
"GET /devices": "bearer",
|
|
252
|
+
"POST /devices/command-result": "bearer",
|
|
253
|
+
"POST /devices/file": "bearer",
|
|
254
|
+
"GET /devices/file": "bearer",
|
|
255
|
+
} as const satisfies Record<string, BridgeRouteAuth>;
|
|
256
|
+
|
|
257
|
+
export type BridgeRouteKey = keyof typeof BRIDGE_ROUTE_AUTH;
|
|
258
|
+
|
|
259
|
+
/** What a route handler receives; `auth` is already evaluated. */
|
|
260
|
+
type RouteContext = {
|
|
261
|
+
req: IncomingMessage;
|
|
262
|
+
res: ServerResponse;
|
|
263
|
+
url: URL;
|
|
264
|
+
auth: AuthState;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
type RouteHandler = (ctx: RouteContext) => void | Promise<void>;
|
|
268
|
+
|
|
188
269
|
export class BridgeServer {
|
|
189
270
|
private server: Server | null = null;
|
|
190
271
|
/**
|
|
@@ -199,6 +280,8 @@ export class BridgeServer {
|
|
|
199
280
|
private tlsIdentity: BridgeTlsIdentity | null = null;
|
|
200
281
|
/** Wrong-token counts per remote address (behind a proxy: per proxy). */
|
|
201
282
|
private authFailures = new Map<string, { count: number; resetAt: number }>();
|
|
283
|
+
/** `METHOD /path` → handler; a Map so lookups only ever hit own entries. */
|
|
284
|
+
private readonly routes: ReadonlyMap<BridgeRouteKey, RouteHandler>;
|
|
202
285
|
|
|
203
286
|
constructor(
|
|
204
287
|
private readonly opts: {
|
|
@@ -217,7 +300,11 @@ export class BridgeServer {
|
|
|
217
300
|
tls?: () => Promise<BridgeTlsIdentity>;
|
|
218
301
|
},
|
|
219
302
|
private readonly handlers: BridgeServerHandlers,
|
|
220
|
-
) {
|
|
303
|
+
) {
|
|
304
|
+
this.routes = new Map(
|
|
305
|
+
Object.entries(this.buildRoutes()) as [BridgeRouteKey, RouteHandler][],
|
|
306
|
+
);
|
|
307
|
+
}
|
|
221
308
|
|
|
222
309
|
getPort(): number {
|
|
223
310
|
return this.port;
|
|
@@ -410,8 +497,8 @@ export class BridgeServer {
|
|
|
410
497
|
}
|
|
411
498
|
// Set once here rather than in corsHeaders(): setHeader values survive
|
|
412
499
|
// every later writeHead(code, {...}) that does not name the same key,
|
|
413
|
-
// so each of the
|
|
414
|
-
//
|
|
500
|
+
// so each of the response sites keeps the grant without threading the
|
|
501
|
+
// origin through all of them.
|
|
415
502
|
if (origin !== undefined && this.isAllowedOrigin(origin)) {
|
|
416
503
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
417
504
|
res.setHeader("Vary", "Origin");
|
|
@@ -439,384 +526,365 @@ export class BridgeServer {
|
|
|
439
526
|
if (auth === "bad") this.recordAuthFailure(remote);
|
|
440
527
|
else if (auth === "ok") this.authFailures.delete(remote);
|
|
441
528
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// backend, and chat count are not for internet scanners to enumerate.
|
|
446
|
-
if (method === "GET" && path === "/health") {
|
|
447
|
-
const base = {
|
|
448
|
-
app: "talon-bridge",
|
|
449
|
-
ok: true,
|
|
450
|
-
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
451
|
-
port: this.port,
|
|
452
|
-
scheme: this.getScheme(),
|
|
453
|
-
// The certificate's own hash — public by definition (any TLS client
|
|
454
|
-
// sees the certificate), surfaced so pairing UIs can display it.
|
|
455
|
-
fingerprint: this.getFingerprint(),
|
|
456
|
-
authRequired: Boolean(this.opts.token),
|
|
457
|
-
};
|
|
458
|
-
if (auth !== "ok") return this.json(res, 200, base);
|
|
459
|
-
const s = this.handlers.status();
|
|
460
|
-
return this.json(res, 200, {
|
|
461
|
-
...base,
|
|
462
|
-
host: this.opts.host,
|
|
463
|
-
startedAt: this.opts.startedAt,
|
|
464
|
-
botName: s.botName,
|
|
465
|
-
backend: s.backend,
|
|
466
|
-
model: s.model,
|
|
467
|
-
activeChats: s.activeChats,
|
|
468
|
-
capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
|
|
469
|
-
});
|
|
470
|
-
}
|
|
529
|
+
const key = `${method} ${path}` as BridgeRouteKey;
|
|
530
|
+
const route = this.routes.get(key);
|
|
531
|
+
const ctx: RouteContext = { req, res, url, auth };
|
|
471
532
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
// single-use grant is what it comes to collect. Serving the page IS the
|
|
475
|
-
// handover, so the grant is spent whichever leg is hit.
|
|
476
|
-
if (method === "GET" && path === "/pair") {
|
|
477
|
-
const token = url.searchParams.get("grant") ?? "";
|
|
478
|
-
const wantsJson =
|
|
479
|
-
url.searchParams.get("format") === "json" ||
|
|
480
|
-
(req.headers.accept ?? "").includes("application/json");
|
|
481
|
-
const served = token
|
|
482
|
-
? this.handlers.openCompanionPair(token, wantsJson ? "json" : "html")
|
|
483
|
-
: null;
|
|
484
|
-
if (!served) {
|
|
485
|
-
return this.json(res, 404, {
|
|
486
|
-
ok: false,
|
|
487
|
-
error: "Unknown, expired, or already-used pairing link",
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
res.writeHead(200, {
|
|
491
|
-
"Content-Type": served.contentType,
|
|
492
|
-
// A pairing payload is a credential; nothing may keep a copy.
|
|
493
|
-
"Cache-Control": "no-store",
|
|
494
|
-
...this.corsHeaders(),
|
|
495
|
-
});
|
|
496
|
-
res.end(served.body);
|
|
533
|
+
if (route && BRIDGE_ROUTE_AUTH[key] === "public") {
|
|
534
|
+
await route(ctx);
|
|
497
535
|
return;
|
|
498
536
|
}
|
|
537
|
+
// Unknown routes are 401 before they are 404: an unauthenticated caller
|
|
538
|
+
// learns nothing about the route map.
|
|
539
|
+
if (auth !== "ok") {
|
|
540
|
+
return this.json(res, 401, { ok: false, error: "Unauthorized" });
|
|
541
|
+
}
|
|
542
|
+
if (!route) return this.json(res, 404, { ok: false, error: "Not found" });
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
await route(ctx);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
548
|
+
return this.json(res, 400, { ok: false, error: msg });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
499
551
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
)
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
552
|
+
/**
|
|
553
|
+
* One handler per BRIDGE_ROUTE_AUTH key. The return type makes the
|
|
554
|
+
* object exhaustive: a route declared in the table without a handler
|
|
555
|
+
* here, or vice versa, does not compile.
|
|
556
|
+
*/
|
|
557
|
+
private buildRoutes(): Record<BridgeRouteKey, RouteHandler> {
|
|
558
|
+
const h = this.handlers;
|
|
559
|
+
const json = (res: ServerResponse, code: number, body: unknown) =>
|
|
560
|
+
this.json(res, code, body);
|
|
561
|
+
const readJson = (req: IncomingMessage) => this.readJson(req);
|
|
562
|
+
|
|
563
|
+
// Streamed device file transfers (see core/mesh/transfers.ts). The
|
|
564
|
+
// one-time `transfer` token authorizes exactly one direction+path; the
|
|
565
|
+
// caller names itself so the token's device binding can be checked.
|
|
566
|
+
const transferToken = (url: URL, res: ServerResponse): string | null => {
|
|
567
|
+
const token = url.searchParams.get("transfer") ?? "";
|
|
568
|
+
if (!token) {
|
|
569
|
+
json(res, 400, { ok: false, error: "transfer required" });
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
return token;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
// ── Pre-auth ───────────────────────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
// Unauthenticated so clients can discover/ping the bridge before they
|
|
579
|
+
// hold a token. Pre-auth it serves only what pairing needs (identity,
|
|
580
|
+
// protocol, fingerprint) — operational details like bot name,
|
|
581
|
+
// backend, and chat count are not for internet scanners to enumerate.
|
|
582
|
+
"GET /health": ({ res, auth }) => {
|
|
583
|
+
const base = {
|
|
584
|
+
app: "talon-bridge",
|
|
585
|
+
ok: true,
|
|
586
|
+
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
587
|
+
port: this.port,
|
|
588
|
+
scheme: this.getScheme(),
|
|
589
|
+
// The certificate's own hash — public by definition (any TLS
|
|
590
|
+
// client sees the certificate), surfaced so pairing UIs can
|
|
591
|
+
// display it.
|
|
592
|
+
fingerprint: this.getFingerprint(),
|
|
593
|
+
authRequired: Boolean(this.opts.token),
|
|
594
|
+
};
|
|
595
|
+
if (auth !== "ok") return json(res, 200, base);
|
|
596
|
+
const s = h.status();
|
|
597
|
+
return json(res, 200, {
|
|
598
|
+
...base,
|
|
599
|
+
host: this.opts.host,
|
|
600
|
+
startedAt: this.opts.startedAt,
|
|
601
|
+
botName: s.botName,
|
|
602
|
+
backend: s.backend,
|
|
603
|
+
model: s.model,
|
|
604
|
+
activeChats: s.activeChats,
|
|
605
|
+
capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
|
|
606
|
+
});
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
// Companion pairing: the phone holds no bridge credential yet, and
|
|
610
|
+
// the single-use grant is what it comes to collect. Serving the page
|
|
611
|
+
// IS the handover, so the grant is spent whichever leg is hit.
|
|
612
|
+
"GET /pair": ({ req, res, url }) => {
|
|
613
|
+
const token = url.searchParams.get("grant") ?? "";
|
|
614
|
+
const wantsJson =
|
|
615
|
+
url.searchParams.get("format") === "json" ||
|
|
616
|
+
(req.headers.accept ?? "").includes("application/json");
|
|
617
|
+
const served = token
|
|
618
|
+
? h.openCompanionPair(token, wantsJson ? "json" : "html")
|
|
619
|
+
: null;
|
|
620
|
+
if (!served) {
|
|
621
|
+
return json(res, 404, {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: "Unknown, expired, or already-used pairing link",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
res.writeHead(200, {
|
|
627
|
+
"Content-Type": served.contentType,
|
|
628
|
+
// A pairing payload is a credential; nothing may keep a copy.
|
|
629
|
+
"Cache-Control": "no-store",
|
|
630
|
+
...this.corsHeaders(),
|
|
513
631
|
});
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
632
|
+
res.end(served.body);
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
// Node provisioning: the target host holds no bridge credential yet —
|
|
636
|
+
// the single-use grant token (minted by make_node_install_link,
|
|
637
|
+
// expiring, one serve per leg) is the entire authorization, the same
|
|
638
|
+
// trust model as streamed-transfer tokens.
|
|
639
|
+
"GET /node/install": ({ res, url }) => {
|
|
640
|
+
const token = url.searchParams.get("provision") ?? "";
|
|
641
|
+
const install = token ? h.openNodeInstall(token) : null;
|
|
642
|
+
if (!install) return this.unknownProvision(res);
|
|
518
643
|
res.writeHead(200, {
|
|
519
644
|
"Content-Type": "text/plain; charset=utf-8",
|
|
520
645
|
"Content-Disposition": `attachment; filename="${install.filename}"`,
|
|
521
646
|
...this.corsHeaders(),
|
|
522
647
|
});
|
|
523
648
|
res.end(install.script);
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
// alone (see sendToDevice); UI clients simply omit it.
|
|
546
|
-
if (method === "GET" && path === "/events")
|
|
547
|
-
return this.openStream(res, deviceIdParam(url));
|
|
548
|
-
|
|
549
|
-
if (method === "GET" && path === "/chats")
|
|
550
|
-
return this.json(res, 200, { chats: this.handlers.listChats() });
|
|
551
|
-
|
|
552
|
-
if (method === "POST" && path === "/chats") {
|
|
553
|
-
const body = await this.readJson(req);
|
|
554
|
-
const chat = this.handlers.createChat(asString(body.title));
|
|
555
|
-
return this.json(res, 200, { chat });
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if (method === "POST" && path === "/chats/rename") {
|
|
559
|
-
const body = await this.readJson(req);
|
|
560
|
-
const chat = this.handlers.renameChat(
|
|
649
|
+
},
|
|
650
|
+
"GET /node/binary": ({ res, url }) => {
|
|
651
|
+
const token = url.searchParams.get("provision") ?? "";
|
|
652
|
+
const binary = token ? h.openNodeBinary(token) : null;
|
|
653
|
+
if (!binary) return this.unknownProvision(res);
|
|
654
|
+
this.streamFile(res, binary);
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
// ── Chats ──────────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
// A mesh client names itself here so device-addressed events reach
|
|
660
|
+
// it alone (see sendToDevice); UI clients simply omit it.
|
|
661
|
+
"GET /events": ({ res, url }) => this.openStream(res, deviceIdParam(url)),
|
|
662
|
+
"GET /chats": ({ res }) => json(res, 200, { chats: h.listChats() }),
|
|
663
|
+
"POST /chats": async ({ req, res }) => {
|
|
664
|
+
const body = await readJson(req);
|
|
665
|
+
json(res, 200, { chat: h.createChat(asString(body.title)) });
|
|
666
|
+
},
|
|
667
|
+
"POST /chats/rename": async ({ req, res }) => {
|
|
668
|
+
const body = await readJson(req);
|
|
669
|
+
const chat = h.renameChat(
|
|
561
670
|
asString(body.chatId) ?? "",
|
|
562
671
|
asString(body.title) ?? "",
|
|
563
672
|
);
|
|
564
673
|
return chat
|
|
565
|
-
?
|
|
566
|
-
:
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const body = await
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
return this.json(res, 200, { ok: true });
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (method === "POST" && path === "/queue") {
|
|
596
|
-
const body = await this.readJson(req);
|
|
597
|
-
this.handlers.queueMessage(
|
|
598
|
-
asString(body.chatId) ?? "",
|
|
599
|
-
asString(body.text) ?? "",
|
|
600
|
-
);
|
|
601
|
-
return this.json(res, 200, { ok: true });
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
if (method === "GET" && path === "/history") {
|
|
674
|
+
? json(res, 200, { chat })
|
|
675
|
+
: json(res, 404, { ok: false, error: "No such chat" });
|
|
676
|
+
},
|
|
677
|
+
"POST /chats/delete": async ({ req, res }) => {
|
|
678
|
+
const body = await readJson(req);
|
|
679
|
+
json(res, 200, { ok: h.deleteChat(asString(body.chatId) ?? "") });
|
|
680
|
+
},
|
|
681
|
+
"POST /chats/reset": async ({ req, res }) => {
|
|
682
|
+
const body = await readJson(req);
|
|
683
|
+
json(res, 200, { ok: h.resetChat(asString(body.chatId) ?? "") });
|
|
684
|
+
},
|
|
685
|
+
"POST /chats/interrupt": async ({ req, res }) => {
|
|
686
|
+
const body = await readJson(req);
|
|
687
|
+
const ok = await h.interruptTurn(asString(body.chatId) ?? "");
|
|
688
|
+
json(res, 200, { ok });
|
|
689
|
+
},
|
|
690
|
+
"POST /chats/pulse": async ({ req, res }) => {
|
|
691
|
+
const body = await readJson(req);
|
|
692
|
+
h.setPulse(asString(body.chatId) ?? "", body.on === true);
|
|
693
|
+
json(res, 200, { ok: true });
|
|
694
|
+
},
|
|
695
|
+
"POST /queue": async ({ req, res }) => {
|
|
696
|
+
const body = await readJson(req);
|
|
697
|
+
h.queueMessage(asString(body.chatId) ?? "", asString(body.text) ?? "");
|
|
698
|
+
json(res, 200, { ok: true });
|
|
699
|
+
},
|
|
700
|
+
"GET /history": ({ res, url }) => {
|
|
605
701
|
const id = url.searchParams.get("chatId") ?? "";
|
|
606
702
|
const before = asPositiveInt(url.searchParams.get("before"));
|
|
607
703
|
const limit = asPositiveInt(url.searchParams.get("limit"));
|
|
608
|
-
|
|
704
|
+
json(res, 200, {
|
|
609
705
|
chatId: id,
|
|
610
|
-
messages:
|
|
706
|
+
messages: h.history(id, { before, limit }),
|
|
611
707
|
});
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (method === "GET" && path === "/search") {
|
|
708
|
+
},
|
|
709
|
+
"GET /search": ({ res, url }) => {
|
|
615
710
|
const q = (url.searchParams.get("q") ?? "").trim();
|
|
616
|
-
if (!q) return
|
|
711
|
+
if (!q) return json(res, 400, { ok: false, error: "q required" });
|
|
617
712
|
const chatId = url.searchParams.get("chatId") ?? undefined;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (method === "POST" && path === "/send") {
|
|
624
|
-
const body = await this.readJson(req);
|
|
713
|
+
json(res, 200, { results: h.search(q, chatId) });
|
|
714
|
+
},
|
|
715
|
+
"POST /send": async ({ req, res }) => {
|
|
716
|
+
const body = await readJson(req);
|
|
625
717
|
const id = asString(body.chatId) ?? "";
|
|
626
718
|
const text = asString(body.text) ?? "";
|
|
627
719
|
const imagePath = asString(body.imagePath);
|
|
628
720
|
const attachmentPath = asString(body.attachmentPath);
|
|
629
|
-
// Text may be empty when an image is attached; require one or the
|
|
721
|
+
// Text may be empty when an image is attached; require one or the
|
|
722
|
+
// other.
|
|
630
723
|
if (!id || (!text.trim() && !attachmentPath))
|
|
631
|
-
return
|
|
724
|
+
return json(res, 400, {
|
|
632
725
|
ok: false,
|
|
633
726
|
error: "chatId and text (or an attachment) required",
|
|
634
727
|
});
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
if (method === "POST" && path === "/devices/register") {
|
|
640
|
-
const body = await this.readJson(req);
|
|
641
|
-
const device = await this.handlers.registerDevice(body);
|
|
642
|
-
return this.json(res, 200, { ok: true, deviceId: device.id });
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
if (method === "POST" && path === "/location") {
|
|
646
|
-
const body = await this.readJson(req);
|
|
647
|
-
await this.handlers.storeLocation(body);
|
|
648
|
-
return this.json(res, 200, { ok: true });
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if (method === "GET" && path === "/devices") {
|
|
652
|
-
return this.json(res, 200, await this.handlers.listDevices());
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (method === "POST" && path === "/devices/command-result") {
|
|
656
|
-
const body = await this.readJson(req);
|
|
657
|
-
// ok:false for a late/unknown correlation id — not an HTTP error,
|
|
658
|
-
// the device's POST was well-formed; nothing was waiting anymore.
|
|
659
|
-
return this.json(res, 200, {
|
|
660
|
-
ok: this.handlers.completeCommand(body),
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Streamed device file transfers (see core/mesh/transfers.ts). The
|
|
665
|
-
// one-time `transfer` token authorizes exactly one direction+path;
|
|
666
|
-
// these sit behind the bridge bearer auth like every device route.
|
|
667
|
-
if (path === "/devices/file") {
|
|
668
|
-
const token = url.searchParams.get("transfer") ?? "";
|
|
669
|
-
if (!token)
|
|
670
|
-
return this.json(res, 400, { ok: false, error: "transfer required" });
|
|
671
|
-
// The caller names itself so the token's device binding can be
|
|
672
|
-
// checked (see core/mesh/transfers.ts take()).
|
|
673
|
-
const from = deviceIdParam(url);
|
|
674
|
-
if (method === "POST") {
|
|
675
|
-
const result = await this.handlers.acceptFileUpload(token, req, from);
|
|
676
|
-
return this.json(res, result.ok ? 200 : 409, result);
|
|
677
|
-
}
|
|
678
|
-
if (method === "GET") {
|
|
679
|
-
const file = await this.handlers.openFileDownload(token, from);
|
|
680
|
-
if (!file)
|
|
681
|
-
return this.json(res, 404, {
|
|
682
|
-
ok: false,
|
|
683
|
-
error: "Unknown or already-used transfer token",
|
|
684
|
-
});
|
|
685
|
-
res.writeHead(200, {
|
|
686
|
-
"Content-Type": "application/octet-stream",
|
|
687
|
-
"Content-Length": String(file.size),
|
|
688
|
-
...this.corsHeaders(),
|
|
689
|
-
});
|
|
690
|
-
const stream = createReadStream(file.path);
|
|
691
|
-
stream.on("error", () => res.destroy());
|
|
692
|
-
stream.pipe(res);
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
if (method === "POST" && path === "/upload") {
|
|
728
|
+
h.send(id, text, { imagePath, attachmentPath });
|
|
729
|
+
json(res, 202, { ok: true });
|
|
730
|
+
},
|
|
731
|
+
"POST /upload": async ({ req, res, url }) => {
|
|
698
732
|
const filename = url.searchParams.get("filename") ?? "upload";
|
|
699
733
|
const contentType =
|
|
700
734
|
req.headers["content-type"] ?? "application/octet-stream";
|
|
701
735
|
const bytes = await this.readRaw(req, MAX_UPLOAD_BYTES);
|
|
702
736
|
if (!bytes.length)
|
|
703
|
-
return
|
|
704
|
-
const result = await
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const id = url.searchParams.get("chatId") ?? undefined;
|
|
710
|
-
return this.json(res, 200, await this.handlers.listModels(id));
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
if (method === "POST" && path === "/model") {
|
|
714
|
-
const body = await this.readJson(req);
|
|
715
|
-
this.handlers.setModel(
|
|
716
|
-
asString(body.chatId) ?? "",
|
|
717
|
-
asString(body.model) ?? "",
|
|
718
|
-
);
|
|
719
|
-
return this.json(res, 200, { ok: true });
|
|
720
|
-
}
|
|
737
|
+
return json(res, 400, { ok: false, error: "Empty upload" });
|
|
738
|
+
const result = await h.upload(filename, contentType, bytes);
|
|
739
|
+
json(res, 200, { ok: true, ...result });
|
|
740
|
+
},
|
|
741
|
+
"GET /media": ({ res, url }) =>
|
|
742
|
+
this.serveMedia(res, url.searchParams.get("id") ?? ""),
|
|
721
743
|
|
|
722
|
-
|
|
723
|
-
const id = url.searchParams.get("chatId") ?? "";
|
|
724
|
-
return this.json(res, 200, this.handlers.listBackends(id));
|
|
725
|
-
}
|
|
744
|
+
// ── Models / backends / effort ─────────────────────────────────────
|
|
726
745
|
|
|
727
|
-
|
|
728
|
-
const
|
|
746
|
+
"GET /models": async ({ res, url }) => {
|
|
747
|
+
const id = url.searchParams.get("chatId") ?? undefined;
|
|
748
|
+
json(res, 200, await h.listModels(id));
|
|
749
|
+
},
|
|
750
|
+
"POST /model": async ({ req, res }) => {
|
|
751
|
+
const body = await readJson(req);
|
|
752
|
+
h.setModel(asString(body.chatId) ?? "", asString(body.model) ?? "");
|
|
753
|
+
json(res, 200, { ok: true });
|
|
754
|
+
},
|
|
755
|
+
"GET /backends": ({ res, url }) =>
|
|
756
|
+
json(res, 200, h.listBackends(url.searchParams.get("chatId") ?? "")),
|
|
757
|
+
"POST /backend": async ({ req, res }) => {
|
|
758
|
+
const body = await readJson(req);
|
|
729
759
|
// Always 200: ok/error is an application result the client renders,
|
|
730
|
-
// not an HTTP-level failure (the client's decoder drops >=400
|
|
731
|
-
|
|
760
|
+
// not an HTTP-level failure (the client's decoder drops >=400
|
|
761
|
+
// bodies).
|
|
762
|
+
const result = await h.setBackend(
|
|
732
763
|
asString(body.chatId) ?? "",
|
|
733
764
|
asString(body.backend) ?? "",
|
|
734
765
|
);
|
|
735
|
-
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const body = await
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (method === "GET" && path === "/media") {
|
|
753
|
-
const id = url.searchParams.get("id") ?? "";
|
|
754
|
-
return await this.serveMedia(res, id);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (method === "GET" && path === "/logs") {
|
|
766
|
+
json(res, 200, result);
|
|
767
|
+
},
|
|
768
|
+
"GET /effort": async ({ res, url }) =>
|
|
769
|
+
json(
|
|
770
|
+
res,
|
|
771
|
+
200,
|
|
772
|
+
await h.effortLevels(url.searchParams.get("chatId") ?? ""),
|
|
773
|
+
),
|
|
774
|
+
"POST /effort": async ({ req, res }) => {
|
|
775
|
+
const body = await readJson(req);
|
|
776
|
+
h.setEffort(asString(body.chatId) ?? "", asString(body.effort) ?? "");
|
|
777
|
+
json(res, 200, { ok: true });
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
// ── Daemon ─────────────────────────────────────────────────────────
|
|
781
|
+
|
|
782
|
+
"GET /logs": ({ res, url }) => {
|
|
758
783
|
const lines = Math.min(
|
|
759
784
|
asPositiveInt(url.searchParams.get("lines")) ?? 200,
|
|
760
785
|
1000,
|
|
761
786
|
);
|
|
762
787
|
const level = url.searchParams.get("level") ?? "";
|
|
763
788
|
const component = url.searchParams.get("component") ?? undefined;
|
|
764
|
-
|
|
765
|
-
entries:
|
|
789
|
+
json(res, 200, {
|
|
790
|
+
entries: h.logs({
|
|
766
791
|
lines,
|
|
767
792
|
minLevel: isLogLevel(level) ? level : undefined,
|
|
768
793
|
component,
|
|
769
794
|
}),
|
|
770
795
|
});
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
const
|
|
778
|
-
// Always 200: ok/error is an application result the client renders,
|
|
779
|
-
// not an HTTP-level failure (mirrors /backend).
|
|
780
|
-
const result = await this.handlers.setPluginEnabled(
|
|
796
|
+
},
|
|
797
|
+
"GET /plugins": ({ res }) => json(res, 200, { plugins: h.listPlugins() }),
|
|
798
|
+
"POST /plugins/toggle": async ({ req, res }) => {
|
|
799
|
+
const body = await readJson(req);
|
|
800
|
+
// Always 200: ok/error is an application result the client renders
|
|
801
|
+
// (mirrors /backend).
|
|
802
|
+
const result = await h.setPluginEnabled(
|
|
781
803
|
asString(body.name) ?? "",
|
|
782
804
|
body.enabled === true,
|
|
783
805
|
);
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
asString(body.name) ?? "",
|
|
794
|
-
body.enabled === true,
|
|
806
|
+
json(res, 200, result);
|
|
807
|
+
},
|
|
808
|
+
"GET /skills": ({ res }) => json(res, 200, { skills: h.listSkills() }),
|
|
809
|
+
"POST /skills/toggle": async ({ req, res }) => {
|
|
810
|
+
const body = await readJson(req);
|
|
811
|
+
json(
|
|
812
|
+
res,
|
|
813
|
+
200,
|
|
814
|
+
h.setSkillEnabled(asString(body.name) ?? "", body.enabled === true),
|
|
795
815
|
);
|
|
796
|
-
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const body = await
|
|
804
|
-
|
|
805
|
-
|
|
816
|
+
},
|
|
817
|
+
"GET /config": ({ res }) => json(res, 200, h.getConfig()),
|
|
818
|
+
"POST /config": async ({ req, res }) => {
|
|
819
|
+
const body = await readJson(req);
|
|
820
|
+
json(res, 200, h.setConfig(body));
|
|
821
|
+
},
|
|
822
|
+
"POST /control": async ({ req, res }) => {
|
|
823
|
+
const body = await readJson(req);
|
|
824
|
+
// Always 200: ok/message is an application result the client
|
|
825
|
+
// renders (mirrors /backend).
|
|
826
|
+
json(res, 200, await h.control(asString(body.action) ?? ""));
|
|
827
|
+
},
|
|
828
|
+
|
|
829
|
+
// ── Mesh ───────────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
"POST /devices/register": async ({ req, res }) => {
|
|
832
|
+
const body = await readJson(req);
|
|
833
|
+
const device = await h.registerDevice(body);
|
|
834
|
+
json(res, 200, { ok: true, deviceId: device.id });
|
|
835
|
+
},
|
|
836
|
+
"POST /location": async ({ req, res }) => {
|
|
837
|
+
const body = await readJson(req);
|
|
838
|
+
await h.storeLocation(body);
|
|
839
|
+
json(res, 200, { ok: true });
|
|
840
|
+
},
|
|
841
|
+
"GET /devices": async ({ res }) => json(res, 200, await h.listDevices()),
|
|
842
|
+
"POST /devices/command-result": async ({ req, res }) => {
|
|
843
|
+
const body = await readJson(req);
|
|
844
|
+
// ok:false for a late/unknown correlation id — not an HTTP error,
|
|
845
|
+
// the device's POST was well-formed; nothing was waiting anymore.
|
|
846
|
+
json(res, 200, { ok: h.completeCommand(body) });
|
|
847
|
+
},
|
|
848
|
+
"POST /devices/file": async ({ req, res, url }) => {
|
|
849
|
+
const token = transferToken(url, res);
|
|
850
|
+
if (token === null) return;
|
|
851
|
+
const result = await h.acceptFileUpload(token, req, deviceIdParam(url));
|
|
852
|
+
json(res, result.ok ? 200 : 409, result);
|
|
853
|
+
},
|
|
854
|
+
"GET /devices/file": async ({ res, url }) => {
|
|
855
|
+
const token = transferToken(url, res);
|
|
856
|
+
if (token === null) return;
|
|
857
|
+
const file = await h.openFileDownload(token, deviceIdParam(url));
|
|
858
|
+
if (!file)
|
|
859
|
+
return json(res, 404, {
|
|
860
|
+
ok: false,
|
|
861
|
+
error: "Unknown or already-used transfer token",
|
|
862
|
+
});
|
|
863
|
+
this.streamFile(res, file);
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
}
|
|
806
867
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
868
|
+
private unknownProvision(res: ServerResponse): void {
|
|
869
|
+
this.json(res, 404, {
|
|
870
|
+
ok: false,
|
|
871
|
+
error: "Unknown, expired, or already-used provisioning token",
|
|
872
|
+
});
|
|
873
|
+
}
|
|
814
874
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
875
|
+
/** Stream a file whose size is already known as an octet-stream body. */
|
|
876
|
+
private streamFile(
|
|
877
|
+
res: ServerResponse,
|
|
878
|
+
file: { path: string; size: number },
|
|
879
|
+
): void {
|
|
880
|
+
res.writeHead(200, {
|
|
881
|
+
"Content-Type": "application/octet-stream",
|
|
882
|
+
"Content-Length": String(file.size),
|
|
883
|
+
...this.corsHeaders(),
|
|
884
|
+
});
|
|
885
|
+
const stream = createReadStream(file.path);
|
|
886
|
+
stream.on("error", () => res.destroy());
|
|
887
|
+
stream.pipe(res);
|
|
820
888
|
}
|
|
821
889
|
|
|
822
890
|
/** Stream an attached image by id. Auth is already enforced by `handle`. */
|