pinomad 0.2.0 → 0.4.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 +60 -182
- package/dist/{chunk-n4bvmcy8.js → chunk-17p6bfvy.js} +4 -4
- package/dist/chunk-302y6by9.js +50 -0
- package/dist/{chunk-5h97zxag.js → chunk-374zqx5h.js} +150 -121
- package/dist/chunk-5785kwej.js +128 -0
- package/dist/{chunk-z4x88s0q.js → chunk-7gmkgyaz.js} +76 -3
- package/dist/chunk-837gh8nr.js +138 -0
- package/dist/{chunk-d9q0dz8s.js → chunk-akgxwg8f.js} +98 -6
- package/dist/chunk-b3f0ced1.js +147 -0
- package/dist/{chunk-q26n39mf.js → chunk-d4y99pfy.js} +2 -2
- package/dist/{chunk-rkfdsacq.js → chunk-fgx2sh7d.js} +1 -1
- package/dist/chunk-knaw1a9g.js +182 -0
- package/dist/chunk-myjp910s.js +253 -0
- package/dist/{chunk-haycqqp1.js → chunk-n73t5xzw.js} +1 -1
- package/dist/{chunk-5712fvyp.js → chunk-prhvksh3.js} +4 -4
- package/dist/chunk-vw5yb5pd.js +1123 -0
- package/dist/chunk-xhwkx9bd.js +139 -0
- package/dist/{chunk-7fqn69ys.js → chunk-xrvmnymc.js} +2 -2
- package/dist/{chunk-fhtkkcgt.js → chunk-z5pmcscx.js} +1491 -794
- package/dist/pinomad.js +14 -9
- package/package.json +7 -7
- package/web/assets/index-8mNNPXPT.js +383 -0
- package/web/assets/{index-KwICnYux.css → index-BFJJgGGo.css} +1 -1
- package/web/index.html +2 -2
- package/dist/chunk-fxaewke2.js +0 -113
- package/dist/chunk-h35fermn.js +0 -92
- package/dist/chunk-qdxhqva8.js +0 -284
- package/web/assets/index-4p91UVoc.js +0 -383
|
@@ -1,103 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
|
+
HOST_STREAM_NAMES,
|
|
2
3
|
INVALID_FRAME_CLOSE_CODE,
|
|
3
4
|
PROTOCOL,
|
|
4
5
|
UNAUTHORIZED_CLOSE_CODE,
|
|
6
|
+
checkoutOf,
|
|
5
7
|
conversationStream,
|
|
6
|
-
docStream
|
|
7
|
-
|
|
8
|
+
docStream,
|
|
9
|
+
homeOf,
|
|
10
|
+
hostStreams,
|
|
11
|
+
hostSupportsCall,
|
|
12
|
+
hostSupportsFeature,
|
|
13
|
+
organize
|
|
14
|
+
} from "./chunk-myjp910s.js";
|
|
8
15
|
|
|
9
16
|
// packages/protocol/src/remote-durable.ts
|
|
10
17
|
import { applyImmutable } from "@earendil-works/chord/delta";
|
|
11
18
|
|
|
12
|
-
// packages/protocol/src/organization.ts
|
|
13
|
-
function organize(index, summaries) {
|
|
14
|
-
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
|
|
15
|
-
const nodes = new Map;
|
|
16
|
-
const topLevel = new Map;
|
|
17
|
-
for (const entry of index.conversations) {
|
|
18
|
-
if (entry.archived === true)
|
|
19
|
-
continue;
|
|
20
|
-
const home = entry.home;
|
|
21
|
-
if (home.kind === "project" && !index.projects.some((project) => project.path === home.path))
|
|
22
|
-
continue;
|
|
23
|
-
const summary = summaryById.get(entry.id);
|
|
24
|
-
if (summary === undefined)
|
|
25
|
-
continue;
|
|
26
|
-
topLevel.set(entry.id, entry);
|
|
27
|
-
nodes.set(entry.id, { summary, children: [] });
|
|
28
|
-
}
|
|
29
|
-
for (const summary of summaries) {
|
|
30
|
-
const chain = [summary];
|
|
31
|
-
let current = summary;
|
|
32
|
-
while (!topLevel.has(current.id)) {
|
|
33
|
-
const parent = current.parent === undefined ? undefined : summaryById.get(current.parent);
|
|
34
|
-
if (parent === undefined || chain.includes(parent))
|
|
35
|
-
break;
|
|
36
|
-
chain.push(parent);
|
|
37
|
-
current = parent;
|
|
38
|
-
}
|
|
39
|
-
if (!topLevel.has(current.id))
|
|
40
|
-
continue;
|
|
41
|
-
for (let i = chain.length - 1;i >= 0; i--) {
|
|
42
|
-
const member = chain[i];
|
|
43
|
-
if (!nodes.has(member.id))
|
|
44
|
-
nodes.set(member.id, { summary: member, children: [] });
|
|
45
|
-
if (i < chain.length - 1) {
|
|
46
|
-
const parent = nodes.get(chain[i + 1].id);
|
|
47
|
-
const child = nodes.get(member.id);
|
|
48
|
-
if (!parent.children.includes(child))
|
|
49
|
-
parent.children.push(child);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const byIdAscending = (a, b) => a.summary.id - b.summary.id;
|
|
54
|
-
const nodeList = (entry) => sortChildren(nodes.get(entry.id));
|
|
55
|
-
const sortChildren = (node) => ({
|
|
56
|
-
summary: node.summary,
|
|
57
|
-
children: [...node.children].sort(byIdAscending).map(sortChildren)
|
|
58
|
-
});
|
|
59
|
-
const entries = [...topLevel.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
60
|
-
return {
|
|
61
|
-
chats: entries.filter((entry) => entry.home.kind === "chat").map(nodeList),
|
|
62
|
-
projects: [...index.projects].sort((a, b) => b.addedAt - a.addedAt).map((project) => ({
|
|
63
|
-
project,
|
|
64
|
-
conversations: entries.filter((entry) => entry.home.kind === "project" && entry.home.path === project.path).map(nodeList)
|
|
65
|
-
}))
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
function checkoutOf(index, summaries, id) {
|
|
69
|
-
const record = index.checkouts?.find((checkout) => checkout.conversationId === id);
|
|
70
|
-
if (record !== undefined)
|
|
71
|
-
return record;
|
|
72
|
-
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
|
|
73
|
-
const seen = new Set;
|
|
74
|
-
let current = summaryById.get(id);
|
|
75
|
-
while (current !== undefined && !seen.has(current.id)) {
|
|
76
|
-
seen.add(current.id);
|
|
77
|
-
const ancestor = index.checkouts?.find((checkout) => checkout.conversationId === current.id);
|
|
78
|
-
if (ancestor !== undefined)
|
|
79
|
-
return ancestor;
|
|
80
|
-
current = current.parent === undefined ? undefined : summaryById.get(current.parent);
|
|
81
|
-
}
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
function homeOf(index, summaries, id) {
|
|
85
|
-
const entry = index.conversations.find((candidate) => candidate.id === id);
|
|
86
|
-
if (entry !== undefined)
|
|
87
|
-
return entry.home;
|
|
88
|
-
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
|
|
89
|
-
const seen = new Set;
|
|
90
|
-
let current = summaryById.get(id);
|
|
91
|
-
while (current !== undefined && !seen.has(current.id)) {
|
|
92
|
-
seen.add(current.id);
|
|
93
|
-
const ancestor = index.conversations.find((candidate) => candidate.id === current.id);
|
|
94
|
-
if (ancestor !== undefined)
|
|
95
|
-
return ancestor.home;
|
|
96
|
-
current = current.parent === undefined ? undefined : summaryById.get(current.parent);
|
|
97
|
-
}
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
19
|
// packages/protocol/src/transport.ts
|
|
102
20
|
function webSocketTransport(url, token) {
|
|
103
21
|
return {
|
|
@@ -147,7 +65,7 @@ class RemoteClient {
|
|
|
147
65
|
#options;
|
|
148
66
|
#listeners = new Set;
|
|
149
67
|
#values = new Map;
|
|
150
|
-
#wanted = new Set
|
|
68
|
+
#wanted = new Set;
|
|
151
69
|
#fresh = new Set;
|
|
152
70
|
#docs = [];
|
|
153
71
|
#toolPresentations = {};
|
|
@@ -160,6 +78,10 @@ class RemoteClient {
|
|
|
160
78
|
#side;
|
|
161
79
|
#nextCall = 1;
|
|
162
80
|
#nextNotice = 1;
|
|
81
|
+
#hostMinor = 0;
|
|
82
|
+
#helloModels = [];
|
|
83
|
+
#helloDefaults = {};
|
|
84
|
+
#hostVersion;
|
|
163
85
|
#attempt = 0;
|
|
164
86
|
#closed = false;
|
|
165
87
|
#notifying = false;
|
|
@@ -249,10 +171,25 @@ class RemoteClient {
|
|
|
249
171
|
this.#connection?.close();
|
|
250
172
|
return;
|
|
251
173
|
}
|
|
174
|
+
const hostMinor = announced !== null && typeof announced === "object" && typeof announced.minor === "number" ? announced.minor : 0;
|
|
175
|
+
this.#hostMinor = hostMinor;
|
|
252
176
|
this.#attempt = 0;
|
|
253
177
|
this.#docs = frame.docs;
|
|
254
178
|
this.#toolPresentations = frame.toolPresentations;
|
|
179
|
+
this.#helloModels = frame.models;
|
|
180
|
+
this.#helloDefaults = frame.defaults ?? {};
|
|
181
|
+
this.#hostVersion = frame.hostVersion;
|
|
255
182
|
this.#fresh.clear();
|
|
183
|
+
const covered = new Set(hostStreams(hostMinor));
|
|
184
|
+
for (const name of HOST_STREAM_NAMES) {
|
|
185
|
+
if (covered.has(name)) {
|
|
186
|
+
this.#wanted.add(name);
|
|
187
|
+
} else {
|
|
188
|
+
this.#wanted.delete(name);
|
|
189
|
+
this.#values.delete(name);
|
|
190
|
+
this.#fresh.delete(name);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
256
193
|
if (this.#current !== undefined)
|
|
257
194
|
for (const stream of this.#conversationStreams(this.#current))
|
|
258
195
|
this.#wanted.add(stream);
|
|
@@ -264,13 +201,13 @@ class RemoteClient {
|
|
|
264
201
|
if (this.#state !== undefined) {
|
|
265
202
|
this.#update({
|
|
266
203
|
session: frame.session,
|
|
267
|
-
models: frame.models,
|
|
268
204
|
toolPresentations: frame.toolPresentations,
|
|
269
|
-
|
|
270
|
-
|
|
205
|
+
connection: "connected",
|
|
206
|
+
host: this.#hostInfo()
|
|
271
207
|
});
|
|
208
|
+
this.#refresh();
|
|
272
209
|
} else
|
|
273
|
-
this.#awaitFirstView(frame);
|
|
210
|
+
this.#awaitFirstView(frame, hostMinor);
|
|
274
211
|
return;
|
|
275
212
|
}
|
|
276
213
|
case "snapshot":
|
|
@@ -311,19 +248,23 @@ class RemoteClient {
|
|
|
311
248
|
return;
|
|
312
249
|
}
|
|
313
250
|
}
|
|
314
|
-
#awaitFirstView(hello) {
|
|
315
|
-
Promise.all(
|
|
251
|
+
#awaitFirstView(hello, hostMinor) {
|
|
252
|
+
Promise.all(hostStreams(hostMinor).map((stream) => this.#snapshot(stream))).then(() => {
|
|
316
253
|
this.#state = {
|
|
317
254
|
session: hello.session,
|
|
318
|
-
organized: { chats: [], projects: [] },
|
|
319
|
-
models: hello.models,
|
|
320
|
-
defaults: hello.defaults ?? {},
|
|
255
|
+
organized: { chats: [], projects: [], archived: [] },
|
|
256
|
+
models: this.#host("models") ?? hello.models,
|
|
257
|
+
defaults: this.#effectiveDefaults(hello.defaults ?? {}),
|
|
321
258
|
notices: [],
|
|
322
259
|
connection: "connected",
|
|
323
260
|
docs: [],
|
|
324
261
|
toolPresentations: hello.toolPresentations,
|
|
325
262
|
devices: [],
|
|
326
|
-
mcp: null
|
|
263
|
+
mcp: null,
|
|
264
|
+
auth: null,
|
|
265
|
+
settings: null,
|
|
266
|
+
agents: null,
|
|
267
|
+
host: this.#hostInfo()
|
|
327
268
|
};
|
|
328
269
|
this.#refresh();
|
|
329
270
|
this.#resolveReady({ view: this.#viewSource(), controller: this.#controller(), close: () => this.#close() });
|
|
@@ -360,8 +301,26 @@ class RemoteClient {
|
|
|
360
301
|
this.#send({ type: "unsubscribe", stream });
|
|
361
302
|
}
|
|
362
303
|
}
|
|
304
|
+
#host(name) {
|
|
305
|
+
return this.#values.get(name);
|
|
306
|
+
}
|
|
307
|
+
#hostInfo() {
|
|
308
|
+
return {
|
|
309
|
+
protocol: { major: PROTOCOL.major, minor: this.#hostMinor },
|
|
310
|
+
...this.#hostVersion === undefined ? {} : { version: this.#hostVersion }
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
#effectiveDefaults(fallback) {
|
|
314
|
+
const settings = this.#host("settings");
|
|
315
|
+
if (settings === undefined)
|
|
316
|
+
return fallback;
|
|
317
|
+
return {
|
|
318
|
+
...settings.defaultModel === null ? {} : { model: settings.defaultModel },
|
|
319
|
+
...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
|
|
320
|
+
};
|
|
321
|
+
}
|
|
363
322
|
#descendsFrom(id, ancestor) {
|
|
364
|
-
const summaries = this.#
|
|
323
|
+
const summaries = this.#host("conversations") ?? [];
|
|
365
324
|
const parentOf = new Map(summaries.map((summary) => [summary.id, summary.parent]));
|
|
366
325
|
let cursor = id;
|
|
367
326
|
for (let depth = 0;cursor !== undefined && depth < 8; depth++) {
|
|
@@ -383,12 +342,13 @@ class RemoteClient {
|
|
|
383
342
|
const current = this.#current;
|
|
384
343
|
const side = this.#side;
|
|
385
344
|
const conversation = current === undefined ? undefined : this.#values.get(conversationStream(current));
|
|
386
|
-
const summaries = this.#
|
|
387
|
-
const index = this.#
|
|
388
|
-
const devices = this.#
|
|
345
|
+
const summaries = this.#host("conversations") ?? [];
|
|
346
|
+
const index = this.#host("index") ?? { projects: [], conversations: [] };
|
|
347
|
+
const devices = this.#host("devices")?.devices ?? [];
|
|
389
348
|
const tasks = this.#wanted.has("tasks") ? this.#values.get("tasks") : undefined;
|
|
390
349
|
this.#update({
|
|
391
350
|
conversation,
|
|
351
|
+
models: this.#host("models") ?? this.#helloModels,
|
|
392
352
|
organized: organize(index, summaries),
|
|
393
353
|
home: current === undefined ? undefined : homeOf(index, summaries, current),
|
|
394
354
|
checkout: current === undefined ? undefined : checkoutOf(index, summaries, current),
|
|
@@ -400,7 +360,11 @@ class RemoteClient {
|
|
|
400
360
|
docs: this.#docsOf(side)
|
|
401
361
|
},
|
|
402
362
|
devices,
|
|
403
|
-
mcp: this.#
|
|
363
|
+
mcp: this.#host("mcp") ?? null,
|
|
364
|
+
auth: this.#host("auth") ?? null,
|
|
365
|
+
settings: this.#host("settings") ?? null,
|
|
366
|
+
agents: this.#host("agents") ?? null,
|
|
367
|
+
defaults: this.#effectiveDefaults(this.#helloDefaults)
|
|
404
368
|
});
|
|
405
369
|
}
|
|
406
370
|
#update(patch) {
|
|
@@ -435,6 +399,11 @@ class RemoteClient {
|
|
|
435
399
|
this.#commands = this.#commands.then(operation).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
|
|
436
400
|
return this.#commands;
|
|
437
401
|
}
|
|
402
|
+
#request(operation) {
|
|
403
|
+
const request = this.#commands.then(operation);
|
|
404
|
+
this.#commands = request.then(() => {}, () => {});
|
|
405
|
+
return request;
|
|
406
|
+
}
|
|
438
407
|
#viewSource() {
|
|
439
408
|
return {
|
|
440
409
|
current: () => this.#state,
|
|
@@ -452,16 +421,33 @@ class RemoteClient {
|
|
|
452
421
|
return this.#current;
|
|
453
422
|
};
|
|
454
423
|
return {
|
|
455
|
-
addProject: (path) => this.#
|
|
424
|
+
addProject: (path) => this.#request(() => this.#call("addProject", { path })),
|
|
425
|
+
listFolders: (path) => this.#request(() => {
|
|
426
|
+
if (!hostSupportsCall(this.#hostMinor, "listFolders")) {
|
|
427
|
+
throw new Error("The host is too old for this (needs protocol 5.3); run `pinomad upgrade` on the host");
|
|
428
|
+
}
|
|
429
|
+
return this.#call("listFolders", path === undefined ? {} : { path });
|
|
430
|
+
}),
|
|
456
431
|
removeProject: (path) => this.#command(() => this.#call("removeProject", { path })),
|
|
432
|
+
renameConversation: (id, title) => this.#request(async () => {
|
|
433
|
+
if (!hostSupportsCall(this.#hostMinor, "renameConversation")) {
|
|
434
|
+
throw new Error("The host is too old for this (needs protocol 5.4); run `pinomad upgrade` on the host");
|
|
435
|
+
}
|
|
436
|
+
await this.#call("renameConversation", { conversationId: id, title });
|
|
437
|
+
}),
|
|
457
438
|
createConversation: (home, text, options) => this.#command(async () => {
|
|
439
|
+
const images = options?.images ?? [];
|
|
440
|
+
if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
|
|
441
|
+
throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
|
|
442
|
+
}
|
|
458
443
|
const { conversationId: conversationId2 } = await this.#call("createConversation", {
|
|
459
444
|
home,
|
|
460
445
|
text,
|
|
461
446
|
requestId: newRequestId(),
|
|
462
447
|
...options?.checkout === undefined ? {} : { checkout: options.checkout },
|
|
463
448
|
...options?.model === undefined ? {} : { model: options.model },
|
|
464
|
-
...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }
|
|
449
|
+
...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel },
|
|
450
|
+
...images.length === 0 ? {} : { images }
|
|
465
451
|
});
|
|
466
452
|
await this.#switch(conversationId2);
|
|
467
453
|
}),
|
|
@@ -472,7 +458,19 @@ class RemoteClient {
|
|
|
472
458
|
if (archived && this.#side !== undefined && this.#descendsFrom(this.#side, id))
|
|
473
459
|
await this.#showSide(undefined);
|
|
474
460
|
}),
|
|
475
|
-
submit: (text, whenBusy,
|
|
461
|
+
submit: (text, whenBusy, options) => this.#command(() => {
|
|
462
|
+
const images = options?.images ?? [];
|
|
463
|
+
if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
|
|
464
|
+
throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
|
|
465
|
+
}
|
|
466
|
+
return this.#call("submit", {
|
|
467
|
+
conversationId: options?.target ?? conversationId(),
|
|
468
|
+
text,
|
|
469
|
+
whenBusy,
|
|
470
|
+
requestId: options?.requestId ?? newRequestId(),
|
|
471
|
+
...images.length === 0 ? {} : { images }
|
|
472
|
+
});
|
|
473
|
+
}),
|
|
476
474
|
compact: (instructions) => this.#command(() => this.#call("compact", { conversationId: conversationId(), ...instructions === undefined ? {} : { instructions } })),
|
|
477
475
|
abort: (target) => {
|
|
478
476
|
const targetConversation = target ?? this.#current;
|
|
@@ -511,17 +509,48 @@ class RemoteClient {
|
|
|
511
509
|
await this.#call("submit", { conversationId: forked, text: prompt, whenBusy: "followUp", requestId: newRequestId() });
|
|
512
510
|
}),
|
|
513
511
|
answer: (kind, requestId, answers, target) => this.#command(() => this.#call("answer", { conversationId: target ?? conversationId(), kind, requestId, answers })),
|
|
514
|
-
changes: () => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
this.#
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
512
|
+
changes: () => this.#request(() => this.#call("changes", { conversationId: conversationId() })),
|
|
513
|
+
createPairing: () => this.#request(() => this.#call("createPairing", {})),
|
|
514
|
+
revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey })),
|
|
515
|
+
logout: (providerId) => this.#command(() => {
|
|
516
|
+
if (!hostSupportsCall(this.#hostMinor, "logout")) {
|
|
517
|
+
throw new Error("The host is too old for this (needs protocol 5.1); run `pinomad upgrade` on the host");
|
|
518
|
+
}
|
|
519
|
+
return this.#call("logout", { providerId });
|
|
520
|
+
}),
|
|
521
|
+
updateSettings: (patch) => this.#command(() => {
|
|
522
|
+
if (!hostSupportsCall(this.#hostMinor, "updateSettings")) {
|
|
523
|
+
throw new Error("The host is too old for this (needs protocol 5.2); run `pinomad upgrade` on the host");
|
|
524
|
+
}
|
|
525
|
+
if (patch.acceptAccountDevices !== undefined && !hostSupportsFeature(this.#hostMinor, "acceptAccountDevices")) {
|
|
526
|
+
throw new Error("The host is too old for this (needs protocol 5.8); run `pinomad upgrade` on the host");
|
|
527
|
+
}
|
|
528
|
+
return this.#call("updateSettings", { patch });
|
|
529
|
+
}),
|
|
530
|
+
createAgent: (role) => this.#request(async () => {
|
|
531
|
+
if (!hostSupportsCall(this.#hostMinor, "createAgent")) {
|
|
532
|
+
throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
|
|
533
|
+
}
|
|
534
|
+
await this.#call("createAgent", { role });
|
|
535
|
+
}),
|
|
536
|
+
updateAgent: (role) => this.#request(async () => {
|
|
537
|
+
if (!hostSupportsCall(this.#hostMinor, "updateAgent")) {
|
|
538
|
+
throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
|
|
539
|
+
}
|
|
540
|
+
await this.#call("updateAgent", { role });
|
|
541
|
+
}),
|
|
542
|
+
deleteAgent: (name) => this.#request(async () => {
|
|
543
|
+
if (!hostSupportsCall(this.#hostMinor, "deleteAgent")) {
|
|
544
|
+
throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
|
|
545
|
+
}
|
|
546
|
+
await this.#call("deleteAgent", { name });
|
|
547
|
+
}),
|
|
548
|
+
setPushToken: (token) => this.#request(async () => {
|
|
549
|
+
if (!hostSupportsCall(this.#hostMinor, "setPushToken")) {
|
|
550
|
+
throw new Error("The host is too old for this (needs protocol 5.7); run `pinomad upgrade` on the host");
|
|
551
|
+
}
|
|
552
|
+
await this.#call("setPushToken", { token });
|
|
553
|
+
})
|
|
525
554
|
};
|
|
526
555
|
}
|
|
527
556
|
async#switch(id) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
relayWsBase
|
|
3
|
+
} from "./chunk-knaw1a9g.js";
|
|
4
|
+
import {
|
|
5
|
+
RELAY_CLOSE,
|
|
6
|
+
decodeRelayToHost,
|
|
7
|
+
encodeRelayFrame,
|
|
8
|
+
relayHostId,
|
|
9
|
+
signRelayChallenge,
|
|
10
|
+
signRelayChallengeV2,
|
|
11
|
+
toBase64Url
|
|
12
|
+
} from "./chunk-7gmkgyaz.js";
|
|
13
|
+
|
|
14
|
+
// apps/host/src/cli/server-ops.ts
|
|
15
|
+
import { WebSocket } from "ws";
|
|
16
|
+
|
|
17
|
+
// packages/client/src/account.ts
|
|
18
|
+
var fallbackFetch = (url, init) => fetch(url, init);
|
|
19
|
+
async function probeAccounts(origin, fetchImpl = fallbackFetch) {
|
|
20
|
+
const timeout = new AbortController;
|
|
21
|
+
const timer = setTimeout(() => timeout.abort(), 5000);
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, { signal: timeout.signal });
|
|
24
|
+
if (response.status === 401)
|
|
25
|
+
return "enabled";
|
|
26
|
+
if (response.status === 200) {
|
|
27
|
+
const text = await response.text();
|
|
28
|
+
let body;
|
|
29
|
+
try {
|
|
30
|
+
body = JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
return "disabled";
|
|
33
|
+
}
|
|
34
|
+
return typeof body?.user === "object" && body.user !== null ? "enabled" : "disabled";
|
|
35
|
+
}
|
|
36
|
+
return response.status === 404 ? "disabled" : "unreachable";
|
|
37
|
+
} catch {
|
|
38
|
+
return "unreachable";
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isSecureServerOrigin(origin) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(origin);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const host = url.hostname;
|
|
51
|
+
const loopback = host === "localhost" || host.endsWith(".localhost") || host.startsWith("127.") || host === "::1" || host === "[::1]";
|
|
52
|
+
return url.protocol === "https:" || url.protocol === "http:" && loopback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// apps/host/src/cli/server-ops.ts
|
|
56
|
+
function assertSecureServerOrigin(origin) {
|
|
57
|
+
try {
|
|
58
|
+
new URL(origin);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error(`not a URL: ${origin}`);
|
|
61
|
+
}
|
|
62
|
+
if (isSecureServerOrigin(origin))
|
|
63
|
+
return;
|
|
64
|
+
throw new Error(`account operations need an https origin (loopback http allowed for development) — got ${origin}`);
|
|
65
|
+
}
|
|
66
|
+
var probeServerAccounts = (origin, fetchImpl = fetch) => probeAccounts(origin, fetchImpl);
|
|
67
|
+
var closeReason = (code, origin) => {
|
|
68
|
+
switch (code) {
|
|
69
|
+
case RELAY_CLOSE.badSignature:
|
|
70
|
+
return `the server rejected this host's registration signature (${code})`;
|
|
71
|
+
case RELAY_CLOSE.hostNotAllowed:
|
|
72
|
+
return `the server does not know this host (${code}) — a pre-accounts server can't do join/leave; upgrade it first`;
|
|
73
|
+
default:
|
|
74
|
+
return `the connection to ${origin} closed (${code})`;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
async function runServerOperation(options) {
|
|
78
|
+
const origin = options.origin.replace(/\/+$/, "");
|
|
79
|
+
assertSecureServerOrigin(origin);
|
|
80
|
+
const hostId = relayHostId(options.signingKey.publicKey);
|
|
81
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
82
|
+
const socket = new WebSocket(`${relayWsBase(origin)}/host`, { handshakeTimeout: timeoutMs });
|
|
83
|
+
return await new Promise((resolve, reject) => {
|
|
84
|
+
const finish = (done) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
socket.close();
|
|
87
|
+
done();
|
|
88
|
+
};
|
|
89
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`no answer from ${origin} within ${timeoutMs / 1000}s`))), timeoutMs);
|
|
90
|
+
socket.once("error", () => {});
|
|
91
|
+
socket.once("close", (code) => finish(() => reject(new Error(closeReason(code, origin)))));
|
|
92
|
+
socket.on("message", (data, isBinary) => {
|
|
93
|
+
let frame;
|
|
94
|
+
try {
|
|
95
|
+
if (isBinary)
|
|
96
|
+
throw new Error("binary frame");
|
|
97
|
+
frame = decodeRelayToHost(data.toString());
|
|
98
|
+
} catch {
|
|
99
|
+
finish(() => reject(new Error(`${origin} is not speaking the pinomad relay protocol`)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (frame.t === "challenge") {
|
|
103
|
+
const noisePublicKey = toBase64Url(options.noisePublicKey);
|
|
104
|
+
const challenge = { relayOrigin: origin, hostId, nonce: frame.nonce };
|
|
105
|
+
socket.send(encodeRelayFrame({
|
|
106
|
+
t: "register",
|
|
107
|
+
hostId,
|
|
108
|
+
signature: signRelayChallenge(options.signingKey.secretKey, challenge),
|
|
109
|
+
signatureV2: signRelayChallengeV2(options.signingKey.secretKey, { ...challenge, noisePublicKey }),
|
|
110
|
+
noisePublicKey,
|
|
111
|
+
op: options.op,
|
|
112
|
+
...options.joinToken === undefined ? {} : { joinToken: options.joinToken },
|
|
113
|
+
...options.name === undefined ? {} : { name: options.name }
|
|
114
|
+
}));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (frame.t === "registered") {
|
|
118
|
+
finish(() => reject(new Error(`${origin} ignored the operation — the server is too old for accounts; upgrade it first`)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (frame.t === "incoming" || frame.t === "pairing-request")
|
|
122
|
+
return;
|
|
123
|
+
finish(() => resolve(frame));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { assertSecureServerOrigin, probeServerAccounts, runServerOperation };
|
|
@@ -290,6 +290,7 @@ function encodeRelayFrame(frame) {
|
|
|
290
290
|
return JSON.stringify(frame);
|
|
291
291
|
}
|
|
292
292
|
var isString2 = (value) => typeof value === "string";
|
|
293
|
+
var isBoundedString = (value, max = 64) => isString2(value) && value.length > 0 && value.length <= max;
|
|
293
294
|
function decodeJson2(text) {
|
|
294
295
|
try {
|
|
295
296
|
return JSON.parse(text);
|
|
@@ -310,16 +311,71 @@ function decodeRelayToHost(text) {
|
|
|
310
311
|
if (!isString2(value.connId))
|
|
311
312
|
throw new Error("malformed incoming");
|
|
312
313
|
return { t: "incoming", connId: value.connId };
|
|
314
|
+
case "pairing-request":
|
|
315
|
+
if (!isBoundedString(value.requestId))
|
|
316
|
+
throw new Error("malformed pairing-request");
|
|
317
|
+
return { t: "pairing-request", requestId: value.requestId };
|
|
318
|
+
case "joined":
|
|
319
|
+
if (!isString2(value.username))
|
|
320
|
+
throw new Error("malformed joined");
|
|
321
|
+
return { t: "joined", username: value.username };
|
|
322
|
+
case "left":
|
|
323
|
+
return { t: "left" };
|
|
324
|
+
case "refused":
|
|
325
|
+
if (!isString2(value.reason))
|
|
326
|
+
throw new Error("malformed refused");
|
|
327
|
+
return { t: "refused", reason: value.reason };
|
|
313
328
|
default:
|
|
314
329
|
throw new Error("unknown relay frame");
|
|
315
330
|
}
|
|
316
331
|
}
|
|
317
332
|
function decodeHostToRelay(text) {
|
|
318
333
|
const value = decodeJson2(text);
|
|
319
|
-
|
|
334
|
+
switch (value?.t) {
|
|
335
|
+
case "register":
|
|
336
|
+
return decodeRegister(value);
|
|
337
|
+
case "pairing":
|
|
338
|
+
if (!isBoundedString(value.requestId) || !isBoundedString(value.secret) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) {
|
|
339
|
+
throw new Error("malformed pairing");
|
|
340
|
+
}
|
|
341
|
+
return { t: "pairing", requestId: value.requestId, secret: value.secret, expiresAt: value.expiresAt };
|
|
342
|
+
case "pairing-refused":
|
|
343
|
+
if (!isBoundedString(value.requestId) || !isBoundedString(value.reason)) {
|
|
344
|
+
throw new Error("malformed pairing-refused");
|
|
345
|
+
}
|
|
346
|
+
return { t: "pairing-refused", requestId: value.requestId, reason: value.reason };
|
|
347
|
+
default:
|
|
348
|
+
throw new Error("unknown host frame");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function decodeRegister(value) {
|
|
352
|
+
if (value.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
|
|
353
|
+
throw new Error("malformed register");
|
|
354
|
+
}
|
|
355
|
+
if (value.op !== undefined && value.op !== "join" && value.op !== "leave")
|
|
356
|
+
throw new Error("malformed register");
|
|
357
|
+
if (value.signatureV2 !== undefined && !isString2(value.signatureV2))
|
|
358
|
+
throw new Error("malformed register");
|
|
359
|
+
if (value.noisePublicKey !== undefined && !isString2(value.noisePublicKey))
|
|
360
|
+
throw new Error("malformed register");
|
|
361
|
+
if (value.joinToken !== undefined && !isString2(value.joinToken))
|
|
362
|
+
throw new Error("malformed register");
|
|
363
|
+
if (value.name !== undefined && !isString2(value.name))
|
|
364
|
+
throw new Error("malformed register");
|
|
365
|
+
if (value.features !== undefined && (!Array.isArray(value.features) || value.features.length > 16 || !value.features.every((feature) => isString2(feature) && feature.length <= 64))) {
|
|
320
366
|
throw new Error("malformed register");
|
|
321
367
|
}
|
|
322
|
-
return {
|
|
368
|
+
return {
|
|
369
|
+
t: "register",
|
|
370
|
+
hostId: value.hostId,
|
|
371
|
+
signature: value.signature,
|
|
372
|
+
...value.signatureV2 === undefined ? {} : { signatureV2: value.signatureV2 },
|
|
373
|
+
...value.noisePublicKey === undefined ? {} : { noisePublicKey: value.noisePublicKey },
|
|
374
|
+
...value.op === undefined ? {} : { op: value.op },
|
|
375
|
+
...value.joinToken === undefined ? {} : { joinToken: value.joinToken },
|
|
376
|
+
...value.name === undefined ? {} : { name: value.name },
|
|
377
|
+
...value.features === undefined ? {} : { features: value.features }
|
|
378
|
+
};
|
|
323
379
|
}
|
|
324
380
|
function relayHostId(publicKey) {
|
|
325
381
|
if (publicKey.length !== 32)
|
|
@@ -343,5 +399,22 @@ function verifyRelayChallenge(signature, args) {
|
|
|
343
399
|
return false;
|
|
344
400
|
}
|
|
345
401
|
}
|
|
402
|
+
function relayChallengeMessageV2(args) {
|
|
403
|
+
return utf82.encode(`pinomad-relay/2
|
|
404
|
+
${args.relayOrigin}
|
|
405
|
+
${args.hostId}
|
|
406
|
+
${args.nonce}
|
|
407
|
+
${args.noisePublicKey}`);
|
|
408
|
+
}
|
|
409
|
+
function signRelayChallengeV2(secretKey, args) {
|
|
410
|
+
return toBase64Url(ed25519.sign(relayChallengeMessageV2(args), secretKey));
|
|
411
|
+
}
|
|
412
|
+
function verifyRelayChallengeV2(signature, args) {
|
|
413
|
+
try {
|
|
414
|
+
return ed25519.verify(fromBase64Url(signature), relayChallengeMessageV2(args), fromBase64Url(args.hostId));
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
346
419
|
|
|
347
|
-
export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge };
|
|
420
|
+
export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2 };
|