talon-agent 3.33.3 → 3.34.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 +6 -5
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/plugins/github/provision.ts +1 -1
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type {
|
|
3
|
+
BackendOption,
|
|
4
|
+
BridgeEvent,
|
|
5
|
+
BridgeStatus,
|
|
6
|
+
ClientChat,
|
|
7
|
+
ClientMessage,
|
|
8
|
+
DeviceInfo,
|
|
9
|
+
DeviceLocation,
|
|
10
|
+
LogEntry,
|
|
11
|
+
LogLevel,
|
|
12
|
+
ModelOption,
|
|
13
|
+
PluginItem,
|
|
14
|
+
SearchResult,
|
|
15
|
+
SkillItem,
|
|
16
|
+
ToggleResult,
|
|
17
|
+
} from "../protocol.js";
|
|
18
|
+
import type { ConfigSnapshot } from "../settings.js";
|
|
19
|
+
|
|
20
|
+
/** Optional attachment references carried alongside a sent message. */
|
|
21
|
+
type SendOptions = {
|
|
22
|
+
/** Relative bridge path to render inline (e.g. `/media?id=…`). */
|
|
23
|
+
imagePath?: string;
|
|
24
|
+
/** Absolute on-disk path handed to the model so it can read the file. */
|
|
25
|
+
attachmentPath?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Everything the transport needs the frontend to implement. */
|
|
29
|
+
export type BridgeServerHandlers = {
|
|
30
|
+
status(): BridgeStatus;
|
|
31
|
+
listChats(): ClientChat[];
|
|
32
|
+
createChat(title?: string): ClientChat;
|
|
33
|
+
renameChat(id: string, title: string): ClientChat | null;
|
|
34
|
+
deleteChat(id: string): boolean;
|
|
35
|
+
/** A page of history: newest window, or the window before `before`. */
|
|
36
|
+
history(
|
|
37
|
+
id: string,
|
|
38
|
+
opts?: { before?: number; limit?: number },
|
|
39
|
+
): ClientMessage[];
|
|
40
|
+
/** Full-text search across chats (or one chat when `chatId` is given). */
|
|
41
|
+
search(query: string, chatId?: string): SearchResult[];
|
|
42
|
+
/** Fire-and-forget: streams its results back through `broadcast`. */
|
|
43
|
+
send(id: string, text: string, opts?: SendOptions): void;
|
|
44
|
+
/** Persist an uploaded image and return its render path + on-disk path. */
|
|
45
|
+
upload(
|
|
46
|
+
filename: string,
|
|
47
|
+
contentType: string,
|
|
48
|
+
bytes: Buffer,
|
|
49
|
+
): Promise<{ imagePath: string; path: string }>;
|
|
50
|
+
listModels(
|
|
51
|
+
id?: string,
|
|
52
|
+
):
|
|
53
|
+
| { active: string; models: ModelOption[] }
|
|
54
|
+
| Promise<{ active: string; models: ModelOption[] }>;
|
|
55
|
+
setModel(id: string, model: string): void;
|
|
56
|
+
/** Backends selectable for a chat + the chat's active backend id. */
|
|
57
|
+
listBackends(id: string): { active: string; backends: BackendOption[] };
|
|
58
|
+
/** Switch a chat to another backend; returns ok + an optional error. */
|
|
59
|
+
setBackend(
|
|
60
|
+
id: string,
|
|
61
|
+
backend: string,
|
|
62
|
+
): Promise<{ ok: boolean; error?: string }>;
|
|
63
|
+
setEffort(id: string, effort: string): void;
|
|
64
|
+
effortLevels(id: string): Promise<{ active: string; levels: string[] }>;
|
|
65
|
+
resetChat(id: string): boolean;
|
|
66
|
+
/** Best-effort interrupt of a chat's in-flight turn. `true` if one was
|
|
67
|
+
* running and got signalled. */
|
|
68
|
+
interruptTurn(id: string): Promise<boolean>;
|
|
69
|
+
setPulse(id: string, on: boolean): void;
|
|
70
|
+
/** Set/replace/clear the chat's queued follow-up (empty text clears). */
|
|
71
|
+
queueMessage(id: string, text: string): void;
|
|
72
|
+
/** Read the daemon's own (allowlisted) settings + health. */
|
|
73
|
+
getConfig(): ConfigSnapshot;
|
|
74
|
+
/** Change daemon settings; returns the fresh snapshot. */
|
|
75
|
+
setConfig(update: Record<string, unknown>): ConfigSnapshot;
|
|
76
|
+
/** Installed plugins (built-ins + configured entries) with state. */
|
|
77
|
+
listPlugins(): PluginItem[];
|
|
78
|
+
/** Enable/disable a plugin; persists + hot-reloads. */
|
|
79
|
+
setPluginEnabled(name: string, enabled: boolean): Promise<ToggleResult>;
|
|
80
|
+
/** Installed skills with state. */
|
|
81
|
+
listSkills(): SkillItem[];
|
|
82
|
+
/** Enable/disable a skill; rebuilds the prompt index. */
|
|
83
|
+
setSkillEnabled(name: string, enabled: boolean): ToggleResult;
|
|
84
|
+
/** Fire a daemon-level control action (e.g. "restart", "dream"). */
|
|
85
|
+
control(action: string): Promise<{ ok: boolean; message: string }>;
|
|
86
|
+
/** Newest daemon log entries (for the client's log viewer). */
|
|
87
|
+
logs(opts: {
|
|
88
|
+
lines: number;
|
|
89
|
+
minLevel?: LogLevel;
|
|
90
|
+
component?: string;
|
|
91
|
+
}): LogEntry[];
|
|
92
|
+
/** Events reconstructing any in-progress turns, for a just-connected client. */
|
|
93
|
+
liveTurnEvents(): BridgeEvent[];
|
|
94
|
+
/** Resolve a media id to an absolute file path (or null if unknown). */
|
|
95
|
+
mediaPath(id: string): string | null;
|
|
96
|
+
/** Register/update one mesh device. */
|
|
97
|
+
registerDevice(body: Record<string, unknown>): Promise<DeviceInfo>;
|
|
98
|
+
/** Store the last-known location for one mesh device. */
|
|
99
|
+
storeLocation(body: Record<string, unknown>): Promise<DeviceLocation>;
|
|
100
|
+
/** List mesh devices and their last-known locations. */
|
|
101
|
+
listDevices():
|
|
102
|
+
| { devices: DeviceInfo[]; locations: DeviceLocation[] }
|
|
103
|
+
| Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
|
|
104
|
+
/** A device answered a device_command; true when a call was waiting. */
|
|
105
|
+
completeCommand(body: Record<string, unknown>): boolean;
|
|
106
|
+
/** A device streams a pull-transfer's file body up (raw request body).
|
|
107
|
+
* `fromDeviceId` is the caller's claimed identity, when it sent one. */
|
|
108
|
+
acceptFileUpload(
|
|
109
|
+
token: string,
|
|
110
|
+
body: IncomingMessage,
|
|
111
|
+
fromDeviceId?: string,
|
|
112
|
+
): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
|
|
113
|
+
/** Resolve a push-transfer token to the file to stream down, or null. */
|
|
114
|
+
openFileDownload(
|
|
115
|
+
token: string,
|
|
116
|
+
fromDeviceId?: string,
|
|
117
|
+
): Promise<{ path: string; size: number } | null>;
|
|
118
|
+
/** Resolve a companion-pairing grant to its page/payload, or null. */
|
|
119
|
+
openCompanionPair(
|
|
120
|
+
token: string,
|
|
121
|
+
format: "html" | "json",
|
|
122
|
+
): { contentType: string; body: string } | null;
|
|
123
|
+
/** Resolve a node-provisioning token to its installer script, or null. */
|
|
124
|
+
openNodeInstall(token: string): { script: string; filename: string } | null;
|
|
125
|
+
/** Resolve a node-provisioning token to the binary to stream, or null. */
|
|
126
|
+
openNodeBinary(token: string): { path: string; size: number } | null;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What a route handler may do with the server that owns it. Built once
|
|
131
|
+
* by BridgeServer as bound closures, so the route modules never see the
|
|
132
|
+
* class — only this surface.
|
|
133
|
+
*/
|
|
134
|
+
export type RouteHost = {
|
|
135
|
+
handlers: BridgeServerHandlers;
|
|
136
|
+
opts: { host: string; token?: string; startedAt: string };
|
|
137
|
+
port: () => number;
|
|
138
|
+
scheme: () => "http" | "https";
|
|
139
|
+
fingerprint: () => string | null;
|
|
140
|
+
json: (res: ServerResponse, code: number, body: unknown) => void;
|
|
141
|
+
readJson: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
|
142
|
+
readRaw: (req: IncomingMessage, max: number) => Promise<Buffer>;
|
|
143
|
+
corsHeaders: () => Record<string, string>;
|
|
144
|
+
streamFile: (
|
|
145
|
+
res: ServerResponse,
|
|
146
|
+
file: { path: string; size: number },
|
|
147
|
+
) => void;
|
|
148
|
+
serveMedia: (res: ServerResponse, id: string) => Promise<void>;
|
|
149
|
+
openStream: (res: ServerResponse, deviceId?: string) => void;
|
|
150
|
+
unknownProvision: (res: ServerResponse) => void;
|
|
151
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One handler per BRIDGE_ROUTE_AUTH key, assembled from the route groups.
|
|
3
|
+
* The return type makes the object exhaustive: a route declared in the
|
|
4
|
+
* table without a handler here, or vice versa, does not compile.
|
|
5
|
+
*/
|
|
6
|
+
import type { RouteHost } from "./host.js";
|
|
7
|
+
import type { BridgeRoutes } from "./table.js";
|
|
8
|
+
import { preAuthRoutes } from "./pre-auth.js";
|
|
9
|
+
import { chatRoutes } from "./chats.js";
|
|
10
|
+
import { modelRoutes } from "./models.js";
|
|
11
|
+
import { daemonRoutes } from "./daemon.js";
|
|
12
|
+
import { meshRoutes } from "./mesh.js";
|
|
13
|
+
|
|
14
|
+
export function buildRoutes(host: RouteHost): BridgeRoutes {
|
|
15
|
+
return {
|
|
16
|
+
...preAuthRoutes(host),
|
|
17
|
+
...chatRoutes(host),
|
|
18
|
+
...modelRoutes(host),
|
|
19
|
+
...daemonRoutes(host),
|
|
20
|
+
...meshRoutes(host),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RouteHost } from "./host.js";
|
|
2
|
+
import type { BridgeRoutes } from "./table.js";
|
|
3
|
+
import type { ServerResponse } from "node:http";
|
|
4
|
+
import { deviceIdParam } from "./params.js";
|
|
5
|
+
|
|
6
|
+
// Streamed device file transfers (see core/mesh/transfers.ts). The
|
|
7
|
+
// one-time `transfer` token authorizes exactly one direction+path; the
|
|
8
|
+
// caller names itself so the token's device binding can be checked.
|
|
9
|
+
function transferToken(
|
|
10
|
+
host: RouteHost,
|
|
11
|
+
url: URL,
|
|
12
|
+
res: ServerResponse,
|
|
13
|
+
): string | null {
|
|
14
|
+
const token = url.searchParams.get("transfer") ?? "";
|
|
15
|
+
if (!token) {
|
|
16
|
+
host.json(res, 400, { ok: false, error: "transfer required" });
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function meshRoutes(
|
|
23
|
+
host: RouteHost,
|
|
24
|
+
): Pick<
|
|
25
|
+
BridgeRoutes,
|
|
26
|
+
| "POST /devices/register"
|
|
27
|
+
| "POST /location"
|
|
28
|
+
| "GET /devices"
|
|
29
|
+
| "POST /devices/command-result"
|
|
30
|
+
| "POST /devices/file"
|
|
31
|
+
| "GET /devices/file"
|
|
32
|
+
> {
|
|
33
|
+
const { json, readJson, handlers: h } = host;
|
|
34
|
+
return {
|
|
35
|
+
// ── Mesh ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
"POST /devices/register": async ({ req, res }) => {
|
|
38
|
+
const body = await readJson(req);
|
|
39
|
+
const device = await h.registerDevice(body);
|
|
40
|
+
json(res, 200, { ok: true, deviceId: device.id });
|
|
41
|
+
},
|
|
42
|
+
"POST /location": async ({ req, res }) => {
|
|
43
|
+
const body = await readJson(req);
|
|
44
|
+
await h.storeLocation(body);
|
|
45
|
+
json(res, 200, { ok: true });
|
|
46
|
+
},
|
|
47
|
+
"GET /devices": async ({ res }) => json(res, 200, await h.listDevices()),
|
|
48
|
+
"POST /devices/command-result": async ({ req, res }) => {
|
|
49
|
+
const body = await readJson(req);
|
|
50
|
+
// ok:false for a late/unknown correlation id — not an HTTP error,
|
|
51
|
+
// the device's POST was well-formed; nothing was waiting anymore.
|
|
52
|
+
json(res, 200, { ok: h.completeCommand(body) });
|
|
53
|
+
},
|
|
54
|
+
"POST /devices/file": async ({ req, res, url }) => {
|
|
55
|
+
const token = transferToken(host, url, res);
|
|
56
|
+
if (token === null) return;
|
|
57
|
+
const result = await h.acceptFileUpload(token, req, deviceIdParam(url));
|
|
58
|
+
json(res, result.ok ? 200 : 409, result);
|
|
59
|
+
},
|
|
60
|
+
"GET /devices/file": async ({ res, url }) => {
|
|
61
|
+
const token = transferToken(host, url, res);
|
|
62
|
+
if (token === null) return;
|
|
63
|
+
const file = await h.openFileDownload(token, deviceIdParam(url));
|
|
64
|
+
if (!file)
|
|
65
|
+
return json(res, 404, {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: "Unknown or already-used transfer token",
|
|
68
|
+
});
|
|
69
|
+
host.streamFile(res, file);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RouteHost } from "./host.js";
|
|
2
|
+
import type { BridgeRoutes } from "./table.js";
|
|
3
|
+
import { asString } from "./params.js";
|
|
4
|
+
|
|
5
|
+
export function modelRoutes(
|
|
6
|
+
host: RouteHost,
|
|
7
|
+
): Pick<
|
|
8
|
+
BridgeRoutes,
|
|
9
|
+
| "GET /models"
|
|
10
|
+
| "POST /model"
|
|
11
|
+
| "GET /backends"
|
|
12
|
+
| "POST /backend"
|
|
13
|
+
| "GET /effort"
|
|
14
|
+
| "POST /effort"
|
|
15
|
+
> {
|
|
16
|
+
const { json, readJson, handlers: h } = host;
|
|
17
|
+
return {
|
|
18
|
+
// ── Models / backends / effort ─────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
"GET /models": async ({ res, url }) => {
|
|
21
|
+
const id = url.searchParams.get("chatId") ?? undefined;
|
|
22
|
+
json(res, 200, await h.listModels(id));
|
|
23
|
+
},
|
|
24
|
+
"POST /model": async ({ req, res }) => {
|
|
25
|
+
const body = await readJson(req);
|
|
26
|
+
h.setModel(asString(body.chatId) ?? "", asString(body.model) ?? "");
|
|
27
|
+
json(res, 200, { ok: true });
|
|
28
|
+
},
|
|
29
|
+
"GET /backends": ({ res, url }) =>
|
|
30
|
+
json(res, 200, h.listBackends(url.searchParams.get("chatId") ?? "")),
|
|
31
|
+
"POST /backend": async ({ req, res }) => {
|
|
32
|
+
const body = await readJson(req);
|
|
33
|
+
// Always 200: ok/error is an application result the client renders,
|
|
34
|
+
// not an HTTP-level failure (the client's decoder drops >=400
|
|
35
|
+
// bodies).
|
|
36
|
+
const result = await h.setBackend(
|
|
37
|
+
asString(body.chatId) ?? "",
|
|
38
|
+
asString(body.backend) ?? "",
|
|
39
|
+
);
|
|
40
|
+
json(res, 200, result);
|
|
41
|
+
},
|
|
42
|
+
"GET /effort": async ({ res, url }) =>
|
|
43
|
+
json(
|
|
44
|
+
res,
|
|
45
|
+
200,
|
|
46
|
+
await h.effortLevels(url.searchParams.get("chatId") ?? ""),
|
|
47
|
+
),
|
|
48
|
+
"POST /effort": async ({ req, res }) => {
|
|
49
|
+
const body = await readJson(req);
|
|
50
|
+
h.setEffort(asString(body.chatId) ?? "", asString(body.effort) ?? "");
|
|
51
|
+
json(res, 200, { ok: true });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Query/body coercions shared by the bridge's route handlers. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Longest device id a client may claim (`?deviceId=…`). Matches the
|
|
5
|
+
* registry's own id cap — a longer id can never name a real device, and the
|
|
6
|
+
* claim is held for the life of a connection, so it stays a bounded key.
|
|
7
|
+
*/
|
|
8
|
+
const MAX_DEVICE_ID_CHARS = 128;
|
|
9
|
+
|
|
10
|
+
export function asString(v: unknown): string | undefined {
|
|
11
|
+
return typeof v === "string" ? v : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `deviceId` a mesh client claims on `/events` and `/devices/file`.
|
|
16
|
+
* Undefined when absent or blank — every consumer treats "no claim" as the
|
|
17
|
+
* legacy case, so an empty string must never look like a claimed id.
|
|
18
|
+
*/
|
|
19
|
+
export function deviceIdParam(url: URL): string | undefined {
|
|
20
|
+
const raw = (url.searchParams.get("deviceId") ?? "").trim();
|
|
21
|
+
return raw ? raw.slice(0, MAX_DEVICE_ID_CHARS) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse a positive-integer query param; undefined when absent/invalid. */
|
|
25
|
+
export function asPositiveInt(v: string | null): number | undefined {
|
|
26
|
+
if (!v) return undefined;
|
|
27
|
+
const n = Number(v);
|
|
28
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { RouteHost } from "./host.js";
|
|
2
|
+
import type { BridgeRoutes } from "./table.js";
|
|
3
|
+
import { BRIDGE_PROTOCOL_VERSION } from "../protocol.js";
|
|
4
|
+
|
|
5
|
+
export function preAuthRoutes(
|
|
6
|
+
host: RouteHost,
|
|
7
|
+
): Pick<
|
|
8
|
+
BridgeRoutes,
|
|
9
|
+
"GET /health" | "GET /pair" | "GET /node/install" | "GET /node/binary"
|
|
10
|
+
> {
|
|
11
|
+
const { json, handlers: h } = host;
|
|
12
|
+
return {
|
|
13
|
+
// ── Pre-auth ───────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
// Unauthenticated so clients can discover/ping the bridge before they
|
|
16
|
+
// hold a token. Pre-auth it serves only what pairing needs (identity,
|
|
17
|
+
// protocol, fingerprint) — operational details like bot name,
|
|
18
|
+
// backend, and chat count are not for internet scanners to enumerate.
|
|
19
|
+
"GET /health": ({ res, auth }) => {
|
|
20
|
+
const base = {
|
|
21
|
+
app: "talon-bridge",
|
|
22
|
+
ok: true,
|
|
23
|
+
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
24
|
+
port: host.port(),
|
|
25
|
+
scheme: host.scheme(),
|
|
26
|
+
// The certificate's own hash — public by definition (any TLS
|
|
27
|
+
// client sees the certificate), surfaced so pairing UIs can
|
|
28
|
+
// display it.
|
|
29
|
+
fingerprint: host.fingerprint(),
|
|
30
|
+
authRequired: Boolean(host.opts.token),
|
|
31
|
+
};
|
|
32
|
+
if (auth !== "ok") return json(res, 200, base);
|
|
33
|
+
const s = h.status();
|
|
34
|
+
return json(res, 200, {
|
|
35
|
+
...base,
|
|
36
|
+
host: host.opts.host,
|
|
37
|
+
startedAt: host.opts.startedAt,
|
|
38
|
+
botName: s.botName,
|
|
39
|
+
backend: s.backend,
|
|
40
|
+
model: s.model,
|
|
41
|
+
activeChats: s.activeChats,
|
|
42
|
+
capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Companion pairing: the phone holds no bridge credential yet, and
|
|
47
|
+
// the single-use grant is what it comes to collect. Serving the page
|
|
48
|
+
// IS the handover, so the grant is spent whichever leg is hit.
|
|
49
|
+
"GET /pair": ({ req, res, url }) => {
|
|
50
|
+
const token = url.searchParams.get("grant") ?? "";
|
|
51
|
+
const wantsJson =
|
|
52
|
+
url.searchParams.get("format") === "json" ||
|
|
53
|
+
(req.headers.accept ?? "").includes("application/json");
|
|
54
|
+
const served = token
|
|
55
|
+
? h.openCompanionPair(token, wantsJson ? "json" : "html")
|
|
56
|
+
: null;
|
|
57
|
+
if (!served) {
|
|
58
|
+
return json(res, 404, {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "Unknown, expired, or already-used pairing link",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
res.writeHead(200, {
|
|
64
|
+
"Content-Type": served.contentType,
|
|
65
|
+
// A pairing payload is a credential; nothing may keep a copy.
|
|
66
|
+
"Cache-Control": "no-store",
|
|
67
|
+
...host.corsHeaders(),
|
|
68
|
+
});
|
|
69
|
+
res.end(served.body);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Node provisioning: the target host holds no bridge credential yet —
|
|
73
|
+
// the single-use grant token (minted by make_node_install_link,
|
|
74
|
+
// expiring, one serve per leg) is the entire authorization, the same
|
|
75
|
+
// trust model as streamed-transfer tokens.
|
|
76
|
+
"GET /node/install": ({ res, url }) => {
|
|
77
|
+
const token = url.searchParams.get("provision") ?? "";
|
|
78
|
+
const install = token ? h.openNodeInstall(token) : null;
|
|
79
|
+
if (!install) return host.unknownProvision(res);
|
|
80
|
+
res.writeHead(200, {
|
|
81
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
82
|
+
"Content-Disposition": `attachment; filename="${install.filename}"`,
|
|
83
|
+
...host.corsHeaders(),
|
|
84
|
+
});
|
|
85
|
+
res.end(install.script);
|
|
86
|
+
},
|
|
87
|
+
"GET /node/binary": ({ res, url }) => {
|
|
88
|
+
const token = url.searchParams.get("provision") ?? "";
|
|
89
|
+
const binary = token ? h.openNodeBinary(token) : null;
|
|
90
|
+
if (!binary) return host.unknownProvision(res);
|
|
91
|
+
host.streamFile(res, binary);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ok: request carries the right token (or none is required).
|
|
5
|
+
* anonymous: no credential presented — a pre-pairing probe, not an attack.
|
|
6
|
+
* bad: a credential was presented and it is wrong.
|
|
7
|
+
*/
|
|
8
|
+
export type AuthState = "ok" | "anonymous" | "bad";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "public": served without a bearer token. Every entry is gated some other
|
|
12
|
+
* way — a single-use grant minted by the daemon, or (for /health) by
|
|
13
|
+
* answering only what pairing needs until a token is presented.
|
|
14
|
+
* "bearer": the request must carry the bridge token.
|
|
15
|
+
*/
|
|
16
|
+
export type BridgeRouteAuth = "public" | "bearer";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The bridge's routes and the auth tier of each — declared once, here,
|
|
20
|
+
* rather than implied by where an `if` sits relative to the auth check.
|
|
21
|
+
* The security posture of the transport is this table: a route is
|
|
22
|
+
* pre-auth only by appearing in it as "public", and the route test walks
|
|
23
|
+
* every entry and proves the tier holds on the wire. Adding a route
|
|
24
|
+
* without an entry is a type error (`buildRoutes` is exhaustive over
|
|
25
|
+
* these keys); adding one as "public" is a diff a reviewer sees.
|
|
26
|
+
*/
|
|
27
|
+
export const BRIDGE_ROUTE_AUTH = {
|
|
28
|
+
// Pre-auth by design. /health serves pairing data (identity, protocol,
|
|
29
|
+
// fingerprint) to anyone and the operational view only to a token
|
|
30
|
+
// holder. /pair, /node/install and /node/binary hand over a credential
|
|
31
|
+
// to a device that holds none yet; the single-use grant in the query is
|
|
32
|
+
// the entire authorization.
|
|
33
|
+
"GET /health": "public",
|
|
34
|
+
"GET /pair": "public",
|
|
35
|
+
"GET /node/install": "public",
|
|
36
|
+
"GET /node/binary": "public",
|
|
37
|
+
|
|
38
|
+
// Everything a client can do once paired.
|
|
39
|
+
"GET /events": "bearer",
|
|
40
|
+
"GET /chats": "bearer",
|
|
41
|
+
"POST /chats": "bearer",
|
|
42
|
+
"POST /chats/rename": "bearer",
|
|
43
|
+
"POST /chats/delete": "bearer",
|
|
44
|
+
"POST /chats/reset": "bearer",
|
|
45
|
+
"POST /chats/interrupt": "bearer",
|
|
46
|
+
"POST /chats/pulse": "bearer",
|
|
47
|
+
"POST /queue": "bearer",
|
|
48
|
+
"GET /history": "bearer",
|
|
49
|
+
"GET /search": "bearer",
|
|
50
|
+
"POST /send": "bearer",
|
|
51
|
+
"POST /upload": "bearer",
|
|
52
|
+
"GET /media": "bearer",
|
|
53
|
+
"GET /models": "bearer",
|
|
54
|
+
"POST /model": "bearer",
|
|
55
|
+
"GET /backends": "bearer",
|
|
56
|
+
"POST /backend": "bearer",
|
|
57
|
+
"GET /effort": "bearer",
|
|
58
|
+
"POST /effort": "bearer",
|
|
59
|
+
"GET /logs": "bearer",
|
|
60
|
+
"GET /plugins": "bearer",
|
|
61
|
+
"POST /plugins/toggle": "bearer",
|
|
62
|
+
"GET /skills": "bearer",
|
|
63
|
+
"POST /skills/toggle": "bearer",
|
|
64
|
+
"GET /config": "bearer",
|
|
65
|
+
"POST /config": "bearer",
|
|
66
|
+
"POST /control": "bearer",
|
|
67
|
+
|
|
68
|
+
// Mesh. The one-time `transfer` token on /devices/file authorizes one
|
|
69
|
+
// direction+path, but the route still sits behind the bearer like every
|
|
70
|
+
// device route — the token is a scope, not a credential.
|
|
71
|
+
"POST /devices/register": "bearer",
|
|
72
|
+
"POST /location": "bearer",
|
|
73
|
+
"GET /devices": "bearer",
|
|
74
|
+
"POST /devices/command-result": "bearer",
|
|
75
|
+
"POST /devices/file": "bearer",
|
|
76
|
+
"GET /devices/file": "bearer",
|
|
77
|
+
} as const satisfies Record<string, BridgeRouteAuth>;
|
|
78
|
+
|
|
79
|
+
export type BridgeRouteKey = keyof typeof BRIDGE_ROUTE_AUTH;
|
|
80
|
+
|
|
81
|
+
/** What a route handler receives; `auth` is already evaluated. */
|
|
82
|
+
export type RouteContext = {
|
|
83
|
+
req: IncomingMessage;
|
|
84
|
+
res: ServerResponse;
|
|
85
|
+
url: URL;
|
|
86
|
+
auth: AuthState;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type RouteHandler = (ctx: RouteContext) => void | Promise<void>;
|
|
90
|
+
|
|
91
|
+
/** Every route with its handler — exhaustive over BRIDGE_ROUTE_AUTH by construction. */
|
|
92
|
+
export type BridgeRoutes = Record<BridgeRouteKey, RouteHandler>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native frontend runtime — the state every bridge module shares.
|
|
3
|
+
*
|
|
4
|
+
* `createNativeFrontend` used to hold all of this as closure variables with
|
|
5
|
+
* every handler nested inside it. It is now one explicit object, constructed
|
|
6
|
+
* once, that each module (chat-wire, context, emit, turn, …) takes as its
|
|
7
|
+
* first parameter. The runtime carries state plus two primitives — the
|
|
8
|
+
* message-id minter and the broadcast sink; the modules own the behaviour.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
12
|
+
import type { Gateway } from "../../core/engine/gateway.js";
|
|
13
|
+
import { getMeshService, type MeshService } from "../../core/mesh/index.js";
|
|
14
|
+
import { NativeChats } from "./chats.js";
|
|
15
|
+
import type { BridgeEvent, ClientToolCall, ContextInfo } from "./protocol.js";
|
|
16
|
+
|
|
17
|
+
/** One tool call observed during a running turn, kept for mid-turn replay. */
|
|
18
|
+
export type LiveToolEntry = {
|
|
19
|
+
call: ClientToolCall;
|
|
20
|
+
startedAt: number;
|
|
21
|
+
done?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A chat's single queued follow-up, with its attachment paths intact. */
|
|
25
|
+
export type QueuedEntry = {
|
|
26
|
+
text: string;
|
|
27
|
+
imagePath?: string;
|
|
28
|
+
attachmentPath?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type NativeRuntime = {
|
|
32
|
+
readonly config: TalonConfig;
|
|
33
|
+
readonly gateway: Gateway;
|
|
34
|
+
/** ISO timestamp of frontend construction — surfaced on /health + status. */
|
|
35
|
+
readonly startedAt: string;
|
|
36
|
+
readonly botName: string;
|
|
37
|
+
readonly chats: NativeChats;
|
|
38
|
+
/**
|
|
39
|
+
* Daemon-wide mesh service (core/mesh). This frontend is its transport:
|
|
40
|
+
* companions register/report/answer through the bridge routes, and the
|
|
41
|
+
* SSE transport (wired in init) pushes locate requests and device commands
|
|
42
|
+
* out. The model's mesh tools are served by the shared gateway actions, so
|
|
43
|
+
* they work from every frontend.
|
|
44
|
+
*/
|
|
45
|
+
readonly mesh: MeshService;
|
|
46
|
+
/**
|
|
47
|
+
* Ephemeral registry mapping a short media id → absolute file path, so the
|
|
48
|
+
* bridge can serve images the bot attaches without exposing raw paths in
|
|
49
|
+
* the URL. Lives for the process; history rows keep only a text
|
|
50
|
+
* placeholder, so images render live in-session (mirroring how the chat
|
|
51
|
+
* frontends behave).
|
|
52
|
+
*/
|
|
53
|
+
readonly media: Map<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Live context-window fill per chat, refreshed at the end of each turn.
|
|
56
|
+
* Cached (not computed inline) so the sync `toClientChat` projection stays
|
|
57
|
+
* sync — it just reads the last computed value.
|
|
58
|
+
*/
|
|
59
|
+
readonly contextByChat: Map<string, ContextInfo>;
|
|
60
|
+
/**
|
|
61
|
+
* In-progress turns, keyed by chat id, so a client that (re)connects
|
|
62
|
+
* mid-turn can be replayed the turn's tool activity instead of waiting for
|
|
63
|
+
* the turn to finish. Holds the same live tool map `runTurn` mutates;
|
|
64
|
+
* cleared at turn end.
|
|
65
|
+
*/
|
|
66
|
+
readonly liveTurns: Map<string, Map<string, LiveToolEntry>>;
|
|
67
|
+
/**
|
|
68
|
+
* The single queued follow-up per chat, held server-side so every
|
|
69
|
+
* connected client shares (and can edit/cancel) the same queue. Keeps the
|
|
70
|
+
* attachment paths so a queued image sends intact when the turn ends.
|
|
71
|
+
*/
|
|
72
|
+
readonly queuedByChat: Map<string, QueuedEntry>;
|
|
73
|
+
/**
|
|
74
|
+
* The most recent assistant message id per chat — turn_end attaches the
|
|
75
|
+
* turn's meta (tools/stats) to this message so history hydration can show
|
|
76
|
+
* what the model did after a reload.
|
|
77
|
+
*/
|
|
78
|
+
readonly lastAssistantId: Map<string, string>;
|
|
79
|
+
/**
|
|
80
|
+
* Monotonic message-id minter. Seeded from the wall clock so ids stay
|
|
81
|
+
* unique and ascending across restarts (history rows persist their ids).
|
|
82
|
+
*/
|
|
83
|
+
nextId(): number;
|
|
84
|
+
/** Fan an event out to every connected bridge client. */
|
|
85
|
+
broadcast(event: BridgeEvent): void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function createNativeRuntime(
|
|
89
|
+
config: TalonConfig,
|
|
90
|
+
gateway: Gateway,
|
|
91
|
+
broadcast: (event: BridgeEvent) => void,
|
|
92
|
+
): NativeRuntime {
|
|
93
|
+
let seq = Date.now();
|
|
94
|
+
return {
|
|
95
|
+
config,
|
|
96
|
+
gateway,
|
|
97
|
+
startedAt: new Date().toISOString(),
|
|
98
|
+
botName: config.botDisplayName || "Talon",
|
|
99
|
+
chats: new NativeChats(),
|
|
100
|
+
mesh: getMeshService(),
|
|
101
|
+
media: new Map(),
|
|
102
|
+
contextByChat: new Map(),
|
|
103
|
+
liveTurns: new Map(),
|
|
104
|
+
queuedByChat: new Map(),
|
|
105
|
+
lastAssistantId: new Map(),
|
|
106
|
+
nextId: () => ++seq,
|
|
107
|
+
broadcast,
|
|
108
|
+
};
|
|
109
|
+
}
|