talon-agent 3.30.0 → 3.32.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/README.md +1 -1
- package/package.json +2 -2
- package/src/core/mesh/companion-pairing.ts +181 -0
- package/src/core/mesh/service.ts +116 -2
- package/src/core/tools/mesh.ts +2 -2
- package/src/frontend/native/index.ts +1 -0
- package/src/frontend/native/server.ts +33 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -1
- package/src/frontend/telegram/commands/info.ts +51 -3
- package/src/frontend/telegram/helpers/diagnostics.ts +80 -1
- package/src/plugins/mempalace/provision.ts +1 -1
package/README.md
CHANGED
|
@@ -329,7 +329,7 @@ On first boot Talon creates a venv at `~/.talon/mempalace-venv` and installs the
|
|
|
329
329
|
"backend": "mempalace",
|
|
330
330
|
"mempalace": {
|
|
331
331
|
"palacePath": "~/.talon/workspace/palace",
|
|
332
|
-
"version": "3.
|
|
332
|
+
"version": "3.9.0",
|
|
333
333
|
"autoUpdate": true,
|
|
334
334
|
"autoProvision": true
|
|
335
335
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.32.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"@kilocode/sdk": "^7.2.22",
|
|
107
107
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
108
108
|
"@openai/agents": "^0.17.0",
|
|
109
|
-
"@openai/codex-sdk": "^0.
|
|
109
|
+
"@openai/codex-sdk": "^0.150.1",
|
|
110
110
|
"@opencode-ai/sdk": "^1.17.4",
|
|
111
111
|
"@playwright/mcp": "0.0.79",
|
|
112
112
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompanionPairStore — one-time grants that connect a phone to this bridge
|
|
3
|
+
* without anyone typing a host, a port, or a bearer token.
|
|
4
|
+
*
|
|
5
|
+
* The problem it removes: a companion joins the mesh by being told the
|
|
6
|
+
* bridge's address, its bearer token, and (over TLS) the certificate
|
|
7
|
+
* fingerprint to pin. Reading those off a terminal and typing them into a
|
|
8
|
+
* phone is the single worst step in setting Talon up, and a mistyped token
|
|
9
|
+
* fails in a way that looks like a network problem.
|
|
10
|
+
*
|
|
11
|
+
* So the daemon mints a grant and hands out ONE link. The bridge serves it
|
|
12
|
+
* on a route that must work pre-auth — the phone holds no credential yet, so
|
|
13
|
+
* the grant token IS the authorization, the same trust model as node
|
|
14
|
+
* provisioning (node-provision.ts) and streamed transfers (transfers.ts):
|
|
15
|
+
* random 192-bit, single-use, expiring unused.
|
|
16
|
+
*
|
|
17
|
+
* GET /pair?grant=<token> → the pairing page (once)
|
|
18
|
+
* GET /pair?grant=<token>&format=json → the same payload as JSON
|
|
19
|
+
*
|
|
20
|
+
* Opening the link on the phone lands on a page whose button is a
|
|
21
|
+
* `talon://pair` deep link carrying the credentials, so the companion fills
|
|
22
|
+
* its own connection form. The page also prints the values for anyone who
|
|
23
|
+
* would rather type them, because a deep link is exactly the thing that
|
|
24
|
+
* silently does nothing when the app isn't installed.
|
|
25
|
+
*
|
|
26
|
+
* The grant is consumed by whichever leg is served first, and both legs
|
|
27
|
+
* render the same claimed grant — by the time a page has rendered, the
|
|
28
|
+
* credentials are already on the phone, so a second serve would only widen
|
|
29
|
+
* the window for someone else to replay the URL.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
/** Unclaimed grants die after this long. A pairing link is used at once. */
|
|
35
|
+
const GRANT_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export type CompanionPairGrant = {
|
|
38
|
+
token: string;
|
|
39
|
+
/** Bridge base URL as reachable from the phone. */
|
|
40
|
+
bridgeUrl: string;
|
|
41
|
+
/** Bearer token the companion will authenticate with. */
|
|
42
|
+
bearerToken: string;
|
|
43
|
+
/** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
|
|
44
|
+
fingerprint?: string;
|
|
45
|
+
/** What this daemon calls itself, so the phone can label the connection. */
|
|
46
|
+
label?: string;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
used: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** What the phone needs to connect, as served to the companion. */
|
|
52
|
+
export type CompanionPairPayload = {
|
|
53
|
+
url: string;
|
|
54
|
+
token: string;
|
|
55
|
+
fingerprint?: string;
|
|
56
|
+
label?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export class CompanionPairStore {
|
|
60
|
+
private readonly grants = new Map<string, CompanionPairGrant>();
|
|
61
|
+
|
|
62
|
+
constructor(private readonly ttlMs = GRANT_TTL_MS) {}
|
|
63
|
+
|
|
64
|
+
create(
|
|
65
|
+
grant: Omit<CompanionPairGrant, "token" | "createdAt" | "used">,
|
|
66
|
+
): CompanionPairGrant {
|
|
67
|
+
this.sweep();
|
|
68
|
+
const full: CompanionPairGrant = {
|
|
69
|
+
...grant,
|
|
70
|
+
// The label is interpolated into generated HTML and a URL query —
|
|
71
|
+
// keep it to characters that can't carry markup out of either.
|
|
72
|
+
...(grant.label
|
|
73
|
+
? { label: grant.label.replace(/[^\w .-]+/g, "").slice(0, 64) }
|
|
74
|
+
: {}),
|
|
75
|
+
token: randomBytes(24).toString("base64url"),
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
used: false,
|
|
78
|
+
};
|
|
79
|
+
this.grants.set(full.token, full);
|
|
80
|
+
return full;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve a live grant — once. */
|
|
84
|
+
claim(token: string): CompanionPairPayload | null {
|
|
85
|
+
this.sweep();
|
|
86
|
+
const grant = this.grants.get(token);
|
|
87
|
+
if (!grant || grant.used) return null;
|
|
88
|
+
grant.used = true;
|
|
89
|
+
this.grants.delete(token);
|
|
90
|
+
return {
|
|
91
|
+
url: grant.bridgeUrl,
|
|
92
|
+
token: grant.bearerToken,
|
|
93
|
+
...(grant.fingerprint ? { fingerprint: grant.fingerprint } : {}),
|
|
94
|
+
...(grant.label ? { label: grant.label } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private sweep(): void {
|
|
99
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
100
|
+
for (const [token, grant] of this.grants) {
|
|
101
|
+
if (grant.createdAt < cutoff) this.grants.delete(token);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The link a human opens on the phone. */
|
|
107
|
+
export function pairLink(grant: CompanionPairGrant): string {
|
|
108
|
+
return `${grant.bridgeUrl}/pair?grant=${grant.token}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `talon://pair` deep link the companion registers for. Carries the
|
|
113
|
+
* credentials themselves rather than the grant, because the grant is spent
|
|
114
|
+
* by the time this reaches the phone — and because a deep link that needed
|
|
115
|
+
* another round trip would fail on exactly the flaky first connection it
|
|
116
|
+
* exists to make painless.
|
|
117
|
+
*/
|
|
118
|
+
export function pairDeepLink(payload: CompanionPairPayload): string {
|
|
119
|
+
const q = new URLSearchParams({ u: payload.url, t: payload.token });
|
|
120
|
+
if (payload.fingerprint) q.set("f", payload.fingerprint);
|
|
121
|
+
if (payload.label) q.set("n", payload.label);
|
|
122
|
+
return `talon://pair?${q.toString()}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Minimal HTML escape for the values interpolated into the pairing page. */
|
|
126
|
+
function esc(s: string): string {
|
|
127
|
+
return s
|
|
128
|
+
.replace(/&/g, "&")
|
|
129
|
+
.replace(/</g, "<")
|
|
130
|
+
.replace(/>/g, ">")
|
|
131
|
+
.replace(/"/g, """);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The page the phone lands on. Deliberately one self-contained file with no
|
|
136
|
+
* external assets: it is served by a bridge the browser has usually just
|
|
137
|
+
* warned about (self-signed cert), often over a LAN with no internet route,
|
|
138
|
+
* and a page that half-loads there is worse than no page at all.
|
|
139
|
+
*/
|
|
140
|
+
export function pairPage(payload: CompanionPairPayload): string {
|
|
141
|
+
const deep = pairDeepLink(payload);
|
|
142
|
+
const fp = payload.fingerprint;
|
|
143
|
+
return `<!doctype html>
|
|
144
|
+
<html lang="en">
|
|
145
|
+
<head>
|
|
146
|
+
<meta charset="utf-8">
|
|
147
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
148
|
+
<title>Connect to Talon</title>
|
|
149
|
+
<style>
|
|
150
|
+
:root { color-scheme: light dark; }
|
|
151
|
+
body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; }
|
|
152
|
+
main { max-width: 32rem; margin: 0 auto; }
|
|
153
|
+
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
|
154
|
+
p.sub { margin: 0 0 1.5rem; opacity: .7; }
|
|
155
|
+
a.go { display: block; text-align: center; padding: 14px; border-radius: 10px;
|
|
156
|
+
background: #2f6fed; color: #fff; text-decoration: none; font-weight: 600; }
|
|
157
|
+
dl { margin: 1.5rem 0 0; }
|
|
158
|
+
dt { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; opacity: .6; margin-top: 1rem; }
|
|
159
|
+
dd { margin: .2rem 0 0; font-family: ui-monospace, monospace; font-size: .85rem; word-break: break-all; }
|
|
160
|
+
footer { margin-top: 2rem; font-size: .8rem; opacity: .6; }
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<main>
|
|
165
|
+
<h1>Connect to Talon</h1>
|
|
166
|
+
<p class="sub">${payload.label ? esc(payload.label) : "This daemon"} is ready to pair.</p>
|
|
167
|
+
<a class="go" href="${esc(deep)}">Open in Talon</a>
|
|
168
|
+
<dl>
|
|
169
|
+
<dt>Bridge</dt><dd>${esc(payload.url)}</dd>
|
|
170
|
+
<dt>Token</dt><dd>${esc(payload.token)}</dd>
|
|
171
|
+
${fp ? `<dt>Certificate</dt><dd>${esc(fp)}</dd>` : ""}
|
|
172
|
+
</dl>
|
|
173
|
+
<footer>
|
|
174
|
+
If the button does nothing, the companion isn't installed yet — install it,
|
|
175
|
+
then enter the values above by hand. This link is single-use and is now spent.
|
|
176
|
+
</footer>
|
|
177
|
+
</main>
|
|
178
|
+
</body>
|
|
179
|
+
</html>
|
|
180
|
+
`;
|
|
181
|
+
}
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -43,6 +43,12 @@ import {
|
|
|
43
43
|
resolveNodeBinary,
|
|
44
44
|
type NodeBinaryResolver,
|
|
45
45
|
} from "./node-binaries.js";
|
|
46
|
+
import {
|
|
47
|
+
CompanionPairStore,
|
|
48
|
+
pairLink,
|
|
49
|
+
pairPage,
|
|
50
|
+
type CompanionPairPayload,
|
|
51
|
+
} from "./companion-pairing.js";
|
|
46
52
|
import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
|
|
47
53
|
import { MeshRegistry } from "./registry.js";
|
|
48
54
|
import { TransferStore } from "./transfers.js";
|
|
@@ -174,6 +180,8 @@ export class MeshService {
|
|
|
174
180
|
private readonly transfers = new TransferStore();
|
|
175
181
|
/** One-time grants for bridge-served node installers. */
|
|
176
182
|
private readonly provision = new NodeProvisionStore();
|
|
183
|
+
/** One-time grants that hand a phone this bridge's connection details. */
|
|
184
|
+
private readonly companionPairs = new CompanionPairStore();
|
|
177
185
|
private readonly resolveNode: NodeBinaryResolver;
|
|
178
186
|
private bridgeInfo: MeshBridgeInfo | null = null;
|
|
179
187
|
private readonly freshFixTimeoutMs: number;
|
|
@@ -888,7 +896,7 @@ export class MeshService {
|
|
|
888
896
|
|
|
889
897
|
/**
|
|
890
898
|
* `update_device`: remote self-update for the companion. Streams a new APK
|
|
891
|
-
* to the device, then tells it to silently install (
|
|
899
|
+
* to the device, then tells it to silently install (root or Shizuku) and
|
|
892
900
|
* restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
|
|
893
901
|
* the connection back on its own — the link drops only for the seconds the
|
|
894
902
|
* process is swapped, no manual reopen.
|
|
@@ -914,7 +922,7 @@ export class MeshService {
|
|
|
914
922
|
if (target.capabilities && !target.capabilities.includes("install_apk")) {
|
|
915
923
|
return {
|
|
916
924
|
ok: false,
|
|
917
|
-
text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and Shizuku enabled (device control on).`,
|
|
925
|
+
text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and root or Shizuku enabled (device control on).`,
|
|
918
926
|
};
|
|
919
927
|
}
|
|
920
928
|
|
|
@@ -1162,6 +1170,112 @@ export class MeshService {
|
|
|
1162
1170
|
};
|
|
1163
1171
|
}
|
|
1164
1172
|
|
|
1173
|
+
/**
|
|
1174
|
+
* Mint a single-use link that connects a phone to this bridge without
|
|
1175
|
+
* anyone typing an address or a token — the companion half of
|
|
1176
|
+
* {@link makeNodeInstallLink}.
|
|
1177
|
+
*
|
|
1178
|
+
* Returns the link plus the values it carries, so a caller (a `/mesh`
|
|
1179
|
+
* reply, say) can print both: the link is the one-tap path, the values are
|
|
1180
|
+
* what someone falls back to when the app isn't installed yet.
|
|
1181
|
+
*/
|
|
1182
|
+
makeCompanionPairLink(
|
|
1183
|
+
label?: unknown,
|
|
1184
|
+
bridgeUrl?: unknown,
|
|
1185
|
+
):
|
|
1186
|
+
| {
|
|
1187
|
+
ok: true;
|
|
1188
|
+
link: string;
|
|
1189
|
+
url: string;
|
|
1190
|
+
token: string;
|
|
1191
|
+
fingerprint?: string;
|
|
1192
|
+
}
|
|
1193
|
+
| { ok: false; text: string } {
|
|
1194
|
+
const info = this.bridgeInfo;
|
|
1195
|
+
if (!info) {
|
|
1196
|
+
return {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
text: "The native bridge isn't running, so there is nothing for a phone to connect to. Enable the native frontend first.",
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
if (!info.token) {
|
|
1202
|
+
return {
|
|
1203
|
+
ok: false,
|
|
1204
|
+
text: "The bridge has no bearer token (loopback-only bind), and companions authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
1208
|
+
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1209
|
+
const grant = this.companionPairs.create({
|
|
1210
|
+
bridgeUrl: base,
|
|
1211
|
+
bearerToken: info.token,
|
|
1212
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1213
|
+
...(typeof label === "string" && label.trim()
|
|
1214
|
+
? { label: label.trim() }
|
|
1215
|
+
: {}),
|
|
1216
|
+
});
|
|
1217
|
+
return {
|
|
1218
|
+
ok: true,
|
|
1219
|
+
link: pairLink(grant),
|
|
1220
|
+
url: base,
|
|
1221
|
+
token: info.token,
|
|
1222
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* GET /pair — serve a pairing grant (single-use), as the landing page or
|
|
1228
|
+
* as the JSON the companion reads.
|
|
1229
|
+
*/
|
|
1230
|
+
openCompanionPair(
|
|
1231
|
+
token: string,
|
|
1232
|
+
format: "html" | "json",
|
|
1233
|
+
): { contentType: string; body: string } | null {
|
|
1234
|
+
const payload = this.companionPairs.claim(token);
|
|
1235
|
+
if (!payload) return null;
|
|
1236
|
+
return format === "json"
|
|
1237
|
+
? {
|
|
1238
|
+
contentType: "application/json; charset=utf-8",
|
|
1239
|
+
body: JSON.stringify(payload satisfies CompanionPairPayload),
|
|
1240
|
+
}
|
|
1241
|
+
: {
|
|
1242
|
+
contentType: "text/html; charset=utf-8",
|
|
1243
|
+
body: pairPage(payload),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* How this bridge is reachable, for an operator asking "what do I point a
|
|
1249
|
+
* device at?".
|
|
1250
|
+
*
|
|
1251
|
+
* The bearer token comes back with it. Deciding who may see a credential is
|
|
1252
|
+
* the caller's job, not this seam's — the Telegram `/mesh` shows it to the
|
|
1253
|
+
* configured admin and withholds it from everyone else — and an operator who
|
|
1254
|
+
* asked their own daemon for its own connection details should get an
|
|
1255
|
+
* answer, not a lecture.
|
|
1256
|
+
*/
|
|
1257
|
+
bridgeReachability():
|
|
1258
|
+
| {
|
|
1259
|
+
ok: true;
|
|
1260
|
+
url: string;
|
|
1261
|
+
authRequired: boolean;
|
|
1262
|
+
token?: string;
|
|
1263
|
+
fingerprint?: string;
|
|
1264
|
+
}
|
|
1265
|
+
| { ok: false; text: string } {
|
|
1266
|
+
const info = this.bridgeInfo;
|
|
1267
|
+
if (!info) return { ok: false, text: "The native bridge isn't running." };
|
|
1268
|
+
const base = this.bridgeBaseUrl(info);
|
|
1269
|
+
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1270
|
+
return {
|
|
1271
|
+
ok: true,
|
|
1272
|
+
url: base,
|
|
1273
|
+
authRequired: Boolean(info.token),
|
|
1274
|
+
...(info.token ? { token: info.token } : {}),
|
|
1275
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1165
1279
|
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
1166
1280
|
openNodeInstall(token: string): { script: string; filename: string } | null {
|
|
1167
1281
|
return this.provision.openScript(token);
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -85,7 +85,7 @@ export const meshTools: ToolDefinition[] = [
|
|
|
85
85
|
{
|
|
86
86
|
name: "device_exec",
|
|
87
87
|
description:
|
|
88
|
-
"Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it
|
|
88
|
+
"Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it at the highest privilege it has (Android: root if the device grants it, else Shizuku's shell UID, else the app UID; the result's `via` says which). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session. For persistent streams (log tailing, watchers), background with output redirected to a file — `cmd > /tmp/out.log 2>&1 &` returns immediately; poll the file with device_read_file.",
|
|
89
89
|
schema: {
|
|
90
90
|
device: deviceParam,
|
|
91
91
|
cmd: z.string().describe("The shell command to run on the device."),
|
|
@@ -167,7 +167,7 @@ export const meshTools: ToolDefinition[] = [
|
|
|
167
167
|
{
|
|
168
168
|
name: "update_device",
|
|
169
169
|
description:
|
|
170
|
-
"Remotely update a Talon companion (Android): stream a new APK from the daemon to the device and silently reinstall it
|
|
170
|
+
"Remotely update a Talon companion (Android): stream a new APK from the daemon to the device and silently reinstall it at an elevated tier (root or Shizuku), keeping app data. The companion's mesh runs in a foreground service that auto-restarts after the package is replaced, so the connection returns on its own within seconds — no manual reopen. The APK is hashed and the device verifies it before installing (a truncated push is refused; a differently-signed APK is refused by Android). Requires the device to have device control on and either root or Shizuku granted. Confirm success with get_device_status afterwards (appVersion should change).",
|
|
171
171
|
schema: {
|
|
172
172
|
device: deviceParam,
|
|
173
173
|
apk_path: z
|
|
@@ -1263,6 +1263,7 @@ export function createNativeFrontend(
|
|
|
1263
1263
|
mesh.acceptFileUpload(token, body, fromDeviceId),
|
|
1264
1264
|
openFileDownload: (token, fromDeviceId) =>
|
|
1265
1265
|
mesh.openFileDownload(token, fromDeviceId),
|
|
1266
|
+
openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
|
|
1266
1267
|
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
1267
1268
|
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
1268
1269
|
};
|
|
@@ -145,6 +145,11 @@ export type BridgeServerHandlers = {
|
|
|
145
145
|
token: string,
|
|
146
146
|
fromDeviceId?: string,
|
|
147
147
|
): Promise<{ path: string; size: number } | null>;
|
|
148
|
+
/** Resolve a companion-pairing grant to its page/payload, or null. */
|
|
149
|
+
openCompanionPair(
|
|
150
|
+
token: string,
|
|
151
|
+
format: "html" | "json",
|
|
152
|
+
): { contentType: string; body: string } | null;
|
|
148
153
|
/** Resolve a node-provisioning token to its installer script, or null. */
|
|
149
154
|
openNodeInstall(token: string): { script: string; filename: string } | null;
|
|
150
155
|
/** Resolve a node-provisioning token to the binary to stream, or null. */
|
|
@@ -464,6 +469,34 @@ export class BridgeServer {
|
|
|
464
469
|
});
|
|
465
470
|
}
|
|
466
471
|
|
|
472
|
+
// Companion pairing is PRE-AUTH for the same reason as node
|
|
473
|
+
// provisioning below: the phone holds no bridge credential yet, and the
|
|
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);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
467
500
|
// Node provisioning runs PRE-AUTH by design: the target host holds no
|
|
468
501
|
// bridge credential yet — the single-use grant token (minted by
|
|
469
502
|
// make_node_install_link, expiring, one serve per leg) is the entire
|
|
@@ -22,7 +22,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
22
22
|
},
|
|
23
23
|
{ command: "status", description: "Session info, usage, and stats" },
|
|
24
24
|
{ command: "ping", description: "Health check with latency" },
|
|
25
|
-
{
|
|
25
|
+
{
|
|
26
|
+
command: "mesh",
|
|
27
|
+
description: "Mesh devices; /mesh link pairs a new one",
|
|
28
|
+
},
|
|
26
29
|
{ command: "model", description: "Show or change model" },
|
|
27
30
|
{ command: "effort", description: "Set thinking effort level" },
|
|
28
31
|
{ command: "pulse", description: "Conversation engagement settings" },
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
* Informational commands — /start, /help, /ping, /plugins.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Bot } from "grammy";
|
|
5
|
+
import type { Bot, Context } from "grammy";
|
|
6
6
|
import { isUserClientReady } from "../userbot.js";
|
|
7
7
|
import { escapeHtml } from "../formatting.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
formatDuration,
|
|
10
|
+
renderMeshPairLink,
|
|
11
|
+
renderMeshReport,
|
|
12
|
+
type MeshReachability,
|
|
13
|
+
} from "../helpers/index.js";
|
|
14
|
+
import { isAuthorizedAdmin } from "./state.js";
|
|
9
15
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
10
16
|
import { getMeshService } from "../../../core/mesh/index.js";
|
|
11
17
|
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
@@ -104,7 +110,49 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
104
110
|
}
|
|
105
111
|
});
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The bridge footer `/mesh` prints for this caller.
|
|
115
|
+
*
|
|
116
|
+
* The admin gets the whole connection profile — URL, bearer token,
|
|
117
|
+
* certificate — because an operator asking their own daemon how to reach
|
|
118
|
+
* itself should get an answer rather than a scavenger hunt through config
|
|
119
|
+
* files. Everyone else gets the address only: a group member reading the
|
|
120
|
+
* fleet has no business holding the key to it.
|
|
121
|
+
*/
|
|
122
|
+
const bridgeFor = (ctx: Context): MeshReachability => {
|
|
123
|
+
const reach = getMeshService().bridgeReachability();
|
|
124
|
+
if (!reach.ok || isAuthorizedAdmin(ctx)) return reach;
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
url: reach.url,
|
|
128
|
+
authRequired: reach.authRequired,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
107
132
|
bot.command("mesh", async (ctx) => {
|
|
133
|
+
const arg = (ctx.match ?? "").toString().trim();
|
|
134
|
+
// `/mesh link` mints a bridge credential and posts it into the chat, so
|
|
135
|
+
// it is admin-gated even though plain `/mesh` is not — reading the fleet
|
|
136
|
+
// is not the same act as handing out the keys to it.
|
|
137
|
+
if (/^(link|pair)\b/i.test(arg)) {
|
|
138
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
139
|
+
await ctx.reply("Only the configured admin can mint a pairing link.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// `/mesh link Car` names the connection on the phone; with no name it
|
|
143
|
+
// inherits the bot's, which is what the operator already calls this
|
|
144
|
+
// daemon everywhere else.
|
|
145
|
+
const named = arg.replace(/^(link|pair)\b/i, "").trim();
|
|
146
|
+
const minted = getMeshService().makeCompanionPairLink(
|
|
147
|
+
named || ctx.me.first_name,
|
|
148
|
+
);
|
|
149
|
+
await ctx.reply(renderMeshPairLink(minted), {
|
|
150
|
+
parse_mode: "HTML",
|
|
151
|
+
link_preview_options: { is_disabled: true },
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
108
156
|
const sent = await ctx.reply("Pinging mesh devices…");
|
|
109
157
|
let results: MeshPingResult[];
|
|
110
158
|
try {
|
|
@@ -122,7 +170,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
122
170
|
bot,
|
|
123
171
|
ctx.chat.id,
|
|
124
172
|
sent.message_id,
|
|
125
|
-
renderMeshReport(results),
|
|
173
|
+
renderMeshReport(results, Date.now(), bridgeFor(ctx)),
|
|
126
174
|
);
|
|
127
175
|
});
|
|
128
176
|
|
|
@@ -310,9 +310,18 @@ function meshDeviceLine(r: MeshPingResult, now: number): string {
|
|
|
310
310
|
export function renderMeshReport(
|
|
311
311
|
results: MeshPingResult[],
|
|
312
312
|
now = Date.now(),
|
|
313
|
+
bridge?: MeshReachability,
|
|
313
314
|
): string {
|
|
314
315
|
if (results.length === 0) {
|
|
315
|
-
|
|
316
|
+
// An empty fleet is the one case where "what do I point a device at?" is
|
|
317
|
+
// the only useful thing to say, so the bridge line carries the answer
|
|
318
|
+
// instead of leaving the operator to hunt for host and port.
|
|
319
|
+
return [
|
|
320
|
+
"<b>Mesh</b>",
|
|
321
|
+
"",
|
|
322
|
+
"<i>No devices have registered yet.</i>",
|
|
323
|
+
...bridgeLines(bridge),
|
|
324
|
+
].join("\n");
|
|
316
325
|
}
|
|
317
326
|
|
|
318
327
|
const responding = results
|
|
@@ -344,6 +353,76 @@ export function renderMeshReport(
|
|
|
344
353
|
section("Responding", responding);
|
|
345
354
|
section("Unreachable", unreachable);
|
|
346
355
|
section("Offline", offline);
|
|
356
|
+
lines.push(...bridgeLines(bridge));
|
|
347
357
|
|
|
348
358
|
return lines.join("\n");
|
|
349
359
|
}
|
|
360
|
+
|
|
361
|
+
/** What `/mesh` says about the bridge a new device would dial. */
|
|
362
|
+
export type MeshReachability =
|
|
363
|
+
| {
|
|
364
|
+
ok: true;
|
|
365
|
+
url: string;
|
|
366
|
+
authRequired: boolean;
|
|
367
|
+
token?: string;
|
|
368
|
+
fingerprint?: string;
|
|
369
|
+
}
|
|
370
|
+
| { ok: false; text: string };
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The bridge footer.
|
|
374
|
+
*
|
|
375
|
+
* The bearer token and certificate appear when the caller passed them, which
|
|
376
|
+
* `/mesh` does for the configured admin and not for anyone else: an operator
|
|
377
|
+
* asking their own daemon how to reach itself should get the answer, while a
|
|
378
|
+
* group member reading the fleet has no business holding the key to it.
|
|
379
|
+
*/
|
|
380
|
+
function bridgeLines(bridge?: MeshReachability): string[] {
|
|
381
|
+
if (!bridge) return [];
|
|
382
|
+
if (!bridge.ok) return ["", `<b>Bridge</b> — ${escapeHtml(bridge.text)}`];
|
|
383
|
+
const auth = bridge.authRequired ? "token required" : "no token";
|
|
384
|
+
return [
|
|
385
|
+
"",
|
|
386
|
+
`<b>Bridge</b> <code>${escapeHtml(bridge.url)}</code> · ${auth}`,
|
|
387
|
+
...(bridge.token ? [`Token <code>${escapeHtml(bridge.token)}</code>`] : []),
|
|
388
|
+
...(bridge.fingerprint
|
|
389
|
+
? [`Certificate <code>${escapeHtml(bridge.fingerprint)}</code>`]
|
|
390
|
+
: []),
|
|
391
|
+
"Run <code>/mesh link</code> for a one-tap pairing link.",
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Render a minted pairing link, or why one couldn't be minted.
|
|
397
|
+
*
|
|
398
|
+
* The values are printed alongside the link because the link's whole payoff
|
|
399
|
+
* — the phone configuring itself — depends on the companion already being
|
|
400
|
+
* installed, and the fallback needs to be right there when it isn't.
|
|
401
|
+
*/
|
|
402
|
+
export function renderMeshPairLink(
|
|
403
|
+
minted:
|
|
404
|
+
| {
|
|
405
|
+
ok: true;
|
|
406
|
+
link: string;
|
|
407
|
+
url: string;
|
|
408
|
+
token: string;
|
|
409
|
+
fingerprint?: string;
|
|
410
|
+
}
|
|
411
|
+
| { ok: false; text: string },
|
|
412
|
+
): string {
|
|
413
|
+
if (!minted.ok) {
|
|
414
|
+
return `<b>Pairing</b>\n\n${escapeHtml(minted.text)}`;
|
|
415
|
+
}
|
|
416
|
+
return [
|
|
417
|
+
"<b>Pair a device</b>",
|
|
418
|
+
"",
|
|
419
|
+
`Open on the phone: ${escapeHtml(minted.link)}`,
|
|
420
|
+
"",
|
|
421
|
+
"Single-use, expires in 10 minutes. If the companion isn't installed yet:",
|
|
422
|
+
` Bridge <code>${escapeHtml(minted.url)}</code>`,
|
|
423
|
+
` Token <code>${escapeHtml(minted.token)}</code>`,
|
|
424
|
+
...(minted.fingerprint
|
|
425
|
+
? [` Certificate <code>${escapeHtml(minted.fingerprint)}</code>`]
|
|
426
|
+
: []),
|
|
427
|
+
].join("\n");
|
|
428
|
+
}
|
|
@@ -47,7 +47,7 @@ import { dirs, files } from "../../util/paths.js";
|
|
|
47
47
|
* to. Bump deliberately, with the canary workflow green — see
|
|
48
48
|
* .github/workflows/native-provision.yml.
|
|
49
49
|
*/
|
|
50
|
-
export const MEMPALACE_PINNED_VERSION = "3.
|
|
50
|
+
export const MEMPALACE_PINNED_VERSION = "3.9.0";
|
|
51
51
|
|
|
52
52
|
/** Minimum python for the managed venv (matches mempalace's supported floor). */
|
|
53
53
|
const PYTHON_MIN = { major: 3, minor: 10 };
|