clavarium 0.2.385-next.1 → 0.2.386-next.1
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/npm-shrinkwrap.json +6 -6
- package/package.json +3 -2
- package/server/api.mjs +10 -1
- package/server/browsers.mjs +295 -0
- package/server/demandes.mjs +51 -2
- package/server/main.mjs +6 -1
- package/server/settingswords.mjs +20 -0
- package/server/tethers.mjs +9 -3
- package/web/app.js +75 -2
- package/web/index.html +13 -0
- package/web/style.css +10 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.386-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.386-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"clavarium-binder": "0.1.62-next.1",
|
|
15
15
|
"clavarium-docket": "0.1.2-next.2",
|
|
16
16
|
"clavarium-parley": "0.17.88-next.1",
|
|
17
|
-
"clavarium-tether": "0.1.0-next.
|
|
17
|
+
"clavarium-tether": "0.1.0-next.58",
|
|
18
18
|
"mammoth": "1.13.0",
|
|
19
19
|
"mermaid": "11.17.2",
|
|
20
20
|
"node-pty": "^1.1.0",
|
|
@@ -800,9 +800,9 @@
|
|
|
800
800
|
}
|
|
801
801
|
},
|
|
802
802
|
"node_modules/clavarium-tether": {
|
|
803
|
-
"version": "0.1.0-next.
|
|
804
|
-
"resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.
|
|
805
|
-
"integrity": "sha512-
|
|
803
|
+
"version": "0.1.0-next.58",
|
|
804
|
+
"resolved": "https://registry.npmjs.org/clavarium-tether/-/clavarium-tether-0.1.0-next.58.tgz",
|
|
805
|
+
"integrity": "sha512-doQf9RcohN4WIA2kQZeuBI6JlRlV9d+yTMw/iT14aqVJBaBh7i1ZIloIxhX2dRxAOFsGLNxuYUdSC4zShoIGJQ==",
|
|
806
806
|
"license": "MIT",
|
|
807
807
|
"bin": {
|
|
808
808
|
"tether": "bin/cli.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.386-next.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"check:hooks": "node tools/check-hooks.mjs",
|
|
37
37
|
"test:e2e": "node --test --test-concurrency=1 \"e2e/*.e2e.mjs\"",
|
|
38
38
|
"toolbox": "node deploy/parley-archive.mjs",
|
|
39
|
+
"extension": "node tools/extension-zips.mjs",
|
|
39
40
|
"acceptance": "node acceptance/run.mjs"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"clavarium-binder": "0.1.62-next.1",
|
|
48
49
|
"clavarium-docket": "0.1.2-next.2",
|
|
49
50
|
"clavarium-parley": "0.17.88-next.1",
|
|
50
|
-
"clavarium-tether": "0.1.0-next.
|
|
51
|
+
"clavarium-tether": "0.1.0-next.58",
|
|
51
52
|
"mammoth": "1.13.0",
|
|
52
53
|
"mermaid": "11.17.2",
|
|
53
54
|
"node-pty": "^1.1.0",
|
package/server/api.mjs
CHANGED
|
@@ -360,7 +360,9 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
360
360
|
const r = await tethers.act('state', { agent: query.get('agent') ?? undefined }, '');
|
|
361
361
|
const data = r.data ?? {};
|
|
362
362
|
const approvals = Array.isArray(data.approvals) ? data.approvals.map((q) => { try { return { ...q, view: demandeView(q) }; } catch { return q; } }) : data.approvals;
|
|
363
|
-
|
|
363
|
+
/* Each paired browser: connected or not, and the tabs its person granted (browsers.mjs). Never a token. */
|
|
364
|
+
const browsersNow = tethers.browsers ? { browsers: tethers.browsers.all(), browsersWaiting: tethers.browsers.waiting() } : {};
|
|
365
|
+
return send(200, { given: tethers.given(), changes: tethers.changes(), ...data, ...(approvals ? { approvals } : {}), ...browsersNow }), true;
|
|
364
366
|
}
|
|
365
367
|
/* Every device, each with all it was given. */
|
|
366
368
|
if (pathname === '/api/devices' && sharing) return send(200, sharing.devices.all()), true;
|
|
@@ -623,10 +625,17 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
623
625
|
const done = await { add: mcpAdd, import: mcpImport, refresh: mcpRefresh, rights: mcpRights, seen: mcpSeen }[mcpOp[1]](ctx, body ?? {});
|
|
624
626
|
return send(mcpOp[1] === 'add' || mcpOp[1] === 'import' ? 201 : 200, done), true;
|
|
625
627
|
}
|
|
628
|
+
/* A browser paired through the Tether extension (browsers.mjs): the code its popup shows, and its name; or paired
|
|
629
|
+
again ({ code, target }). The token it is handed is never in the answer. */
|
|
630
|
+
/* The sites a conversation acts on without asking, at frame: { target, agent, sites: [host | *.host] }. */
|
|
631
|
+
if (pathname === '/api/tether/browser-sites' && tethers?.browsers) return send(200, await tethers.browsers.setSites(tethers, body ?? {})), true;
|
|
632
|
+
if (pathname === '/api/tether/browser-pair' && tethers?.browsers) return send(201, tethers.browsers.pair({ code: body?.code, name: body?.name, target: body?.target })), true;
|
|
626
633
|
/* A server taken away: the key kept for it goes too. */
|
|
627
634
|
const mcpGone = pathname === '/api/tether/remove-target' && tethers ? tethers.store().target(String(body?.target ?? '')) : null;
|
|
628
635
|
const tetherOp = /^\/api\/tether\/(level|allow|unallow|decide|remove-target|stop|resume|job-done|job-file)$/.exec(pathname);
|
|
629
636
|
if (tetherOp && tethers && mcpGone?.kind === 'mcp') { const r = await tethers.act('remove-target', body, ''); if (!r.ok) throw new Refusal(400, r.text); forgetKey(secrets, mcpGone); return send(200, r.data ?? { ok: true }), true; }
|
|
637
|
+
/* A browser taken away: its tokens and its socket go too. */
|
|
638
|
+
if (tetherOp && tethers && mcpGone?.kind === 'browser') { const r = await tethers.act('remove-target', body, ''); if (!r.ok) throw new Refusal(400, r.text); tethers.browsers?.forget(mcpGone.id); return send(200, r.data ?? { ok: true }), true; }
|
|
630
639
|
if (tetherOp && tethers) { const r = await tethers.act(tetherOp[1], body, ''); if (!r.ok) throw new Refusal(400, r.text); return send(200, r.data ?? { ok: true }), true; }
|
|
631
640
|
/* A mailbox's signature (Markdown), added under what the agent sends from it. */
|
|
632
641
|
if (pathname === '/api/tether/signature' && tethers) {
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/* BROWSERS, THROUGH TETHER (08-11): a person's real Chrome or Firefox, paired once through the Tether extension, is a
|
|
2
|
+
* target like a mailbox -- an asset given to a conversation at a level (Off / Read / All with approval / All / Custom).
|
|
3
|
+
* The tabs the person grants in the extension's popup are all an AI can touch; Tether's browser connector refuses any
|
|
4
|
+
* other tab before anything is sent, and the extension refuses it again on its side.
|
|
5
|
+
*
|
|
6
|
+
* THE EXTENSION CONNECTS OUT, to BROWSER_PATH on this server's public address (wss://), under /c/: the front lets /c/
|
|
7
|
+
* through without the web sign-in (deploy/install.sh, skip_auth_routes), as it does for paired devices and voice boxes.
|
|
8
|
+
* The extension cannot sign in; its right is the token it was handed when it was paired, and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* PAIRING. A new extension says { t:"pair", code }: it waits here, in the lobby, its code shown in its popup. The person
|
|
11
|
+
* types that code in Assets › New › Browser (pair): the socket goes to that target's own connector (one per browser,
|
|
12
|
+
* Tether's `browser()`), which mints a long-lived token and hands it over. From then on the extension says
|
|
13
|
+
* { t:"hello", token } each time it connects -- every start of the browser -- and is that browser again, with no code.
|
|
14
|
+
* Paired again only when the person removes it (or pairs it again from its panel).
|
|
15
|
+
*
|
|
16
|
+
* TOKENS ARE NEVER KEPT IN CLEAR: only their SHA-256 (tether-browser-tokens.json). Never logged, never answered, never
|
|
17
|
+
* journaled (Tether journals no token either). A code is never logged.
|
|
18
|
+
*
|
|
19
|
+
* RATE LIMITS, since the endpoint is open to anyone who reaches the address: new sockets per address and minute, sockets
|
|
20
|
+
* waiting unpaired (per address and in all), a waiting socket dropped after a while, a message of a socket not yet paired
|
|
21
|
+
* kept small; and on the person's side, wrong codes counted -- too many in a while, pairing waits. Two sockets offering
|
|
22
|
+
* the same code are both refused (nobody can take a code someone else shows).
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { browser as browserConnector } from 'clavarium-tether/connectors/browser';
|
|
26
|
+
import { no } from 'clavarium-tether/verbs';
|
|
27
|
+
import { JsonFile } from './store.mjs';
|
|
28
|
+
import { Refusal } from './conversations.mjs';
|
|
29
|
+
|
|
30
|
+
export const BROWSER_PATH = '/c/api/browser';
|
|
31
|
+
export const VERBS = ['browser.tabs', 'browser.open', 'browser.shot', 'browser.html', 'browser.console', 'browser.eval', 'browser.click', 'browser.type', 'browser.key', 'browser.scroll', 'browser.drag'];
|
|
32
|
+
/* The acting verbs, each set Yes or Ask under Custom (a verb rule of Tether's, at level frame). */
|
|
33
|
+
export const ACTS = ['browser.open', 'browser.click', 'browser.type', 'browser.key', 'browser.scroll', 'browser.drag', 'browser.eval'];
|
|
34
|
+
/* A socket's message: a screenshot comes back as a data URL. */
|
|
35
|
+
export const MESSAGE_MAX = 32 * 1024 * 1024;
|
|
36
|
+
/* What a socket not yet paired may say at once (hello, pair, its tabs). */
|
|
37
|
+
export const LOBBY_MESSAGE_MAX = 256 * 1024;
|
|
38
|
+
export const LOBBY_MAX = 50;
|
|
39
|
+
export const LOBBY_PER_ADDRESS = 4;
|
|
40
|
+
export const LOBBY_MS = 15 * 60_000;
|
|
41
|
+
export const CONNECTS_PER_MINUTE = 20;
|
|
42
|
+
export const WRONG_MAX = 5;
|
|
43
|
+
export const WRONG_WINDOW_MS = 10 * 60_000;
|
|
44
|
+
const TITLE_MAX = 80;
|
|
45
|
+
export const SITES_MAX = 100;
|
|
46
|
+
|
|
47
|
+
const hash = (token) => createHash('sha256').update(String(token)).digest('hex');
|
|
48
|
+
const slug = (s) => String(s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
49
|
+
const isLoop = (ip) => /^(127\.|::1$|::ffff:127\.)/.test(String(ip ?? ''));
|
|
50
|
+
|
|
51
|
+
/* Who connects: the address the front saw (the last one before it in X-Forwarded-For), else the socket's own. */
|
|
52
|
+
export function addressOf(request) {
|
|
53
|
+
const own = request?.socket?.remoteAddress ?? '';
|
|
54
|
+
if (!isLoop(own)) return own;
|
|
55
|
+
const list = String(request?.headers?.['x-forwarded-for'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
for (let i = list.length - 1; i >= 0; i--) if (!isLoop(list[i])) return list[i];
|
|
57
|
+
return own;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class Browsers {
|
|
61
|
+
#tokens; #store; #now; #log; #make;
|
|
62
|
+
#inst = new Map(); /* target id -> Tether's browser connector for it */
|
|
63
|
+
#lobby = new Set(); /* sockets not paired yet */
|
|
64
|
+
#connects = new Map(); /* address -> times of its recent sockets */
|
|
65
|
+
#wrong = []; /* times of recent wrong codes */
|
|
66
|
+
#listeners = new Set();
|
|
67
|
+
#timer = null;
|
|
68
|
+
|
|
69
|
+
/* `store()`: Tether's store (targets). `log(line)`: never handed a token or a code. `make`: the connector (tests). */
|
|
70
|
+
constructor({ dataDir, store, now = () => Date.now(), log = () => {}, make = browserConnector } = {}) {
|
|
71
|
+
this.#tokens = new JsonFile(dataDir ? `${dataDir}/tether-browser-tokens.json` : '', {});
|
|
72
|
+
this.#store = store; this.#now = now; this.#log = log; this.#make = make;
|
|
73
|
+
this.#timer = setInterval(() => this.#sweep(), 60_000); this.#timer.unref?.();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onChange(fn) { this.#listeners.add(fn); return () => this.#listeners.delete(fn); }
|
|
77
|
+
#changed() { for (const fn of this.#listeners) { try { fn(); } catch { /* its fault */ } } }
|
|
78
|
+
|
|
79
|
+
/* ONE TARGET'S TOKENS, as Tether's connector asks for them: kept as hashes. */
|
|
80
|
+
#tokensOf(id) {
|
|
81
|
+
const read = () => this.#tokens.get()[id] ?? [];
|
|
82
|
+
const write = (list) => { const all = this.#tokens.get(); if (list.length) all[id] = list; else delete all[id]; this.#tokens.set(all); };
|
|
83
|
+
return {
|
|
84
|
+
has: (t) => read().includes(hash(t)),
|
|
85
|
+
add: (t) => { const h = hash(t); const l = read(); if (!l.includes(h)) write([...l, h]); },
|
|
86
|
+
remove: (t) => { const h = hash(t); write(read().filter((x) => x !== h)); },
|
|
87
|
+
list: () => [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
#of(id) {
|
|
91
|
+
let i = this.#inst.get(id);
|
|
92
|
+
if (!i) { i = this.#make({ tokens: this.#tokensOf(id) }); i.onChange(() => this.#changed()); this.#inst.set(id, i); }
|
|
93
|
+
return i;
|
|
94
|
+
}
|
|
95
|
+
#targets() { try { return (this.#store()?.targets() ?? []).filter((t) => t.kind === 'browser' && t.connector === 'browser'); } catch { return []; } }
|
|
96
|
+
|
|
97
|
+
/* THE CONNECTOR THE KEEPER IS GIVEN (connectors.browser): each call goes to its own browser's connector. Rights and
|
|
98
|
+
approvals were applied by the keeper before; this only carries the call. */
|
|
99
|
+
get connector() {
|
|
100
|
+
const self = this;
|
|
101
|
+
return {
|
|
102
|
+
name: 'browser',
|
|
103
|
+
verbs: VERBS,
|
|
104
|
+
async run(verb, target, a, ctx) {
|
|
105
|
+
const i = self.#inst.get(target.id);
|
|
106
|
+
if (!i?.status().connected) no(`${target.title} is not connected: open that browser, with the Tether extension on`);
|
|
107
|
+
return i.run(verb, target, a, ctx);
|
|
108
|
+
},
|
|
109
|
+
/* The site of a granted tab's page, for Tether's site rules (frame: the sites the person allowed). */
|
|
110
|
+
siteOf(target, a) { try { return self.#inst.get(target.id)?.siteOf?.(target, a) ?? ''; } catch { return ''; } },
|
|
111
|
+
/* What a card shows of the act (Tether puts it in card.about): the tab, by its title and address, and what
|
|
112
|
+
the card of Tether's leaves out (a script, a point, a drag). Never a token. */
|
|
113
|
+
async describe(verb, target, a) {
|
|
114
|
+
const tabs = self.#inst.get(target.id)?.status().tabs ?? [];
|
|
115
|
+
const tab = a.tab !== undefined ? tabs.find((x) => String(x.id) === String(a.tab)) : null;
|
|
116
|
+
const out = {};
|
|
117
|
+
if (tab) out.tab = { id: tab.id, title: String(tab.title ?? '').slice(0, 200), url: String(tab.url ?? '').slice(0, 500), site: String(tab.site ?? '') };
|
|
118
|
+
for (const k of ['script', 'selector', 'from', 'to', 'key']) if (typeof a[k] === 'string') out[k] = a[k];
|
|
119
|
+
for (const k of ['x', 'y', 'dx', 'dy']) if (Number.isFinite(Number(a[k])) && a[k] !== undefined) out[k] = Number(a[k]);
|
|
120
|
+
return out;
|
|
121
|
+
},
|
|
122
|
+
close(agent = '*') { for (const i of self.#inst.values()) { try { i.close(agent); } catch { /* gone */ } } },
|
|
123
|
+
shutdown() { self.shutdown(); },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* A SOCKET THE EXTENSION OPENED, wrapped as Tether's channel: { send, onMessage, onClose, close }, and `size` of
|
|
128
|
+
each message (the lobby keeps them small). Returns false when it was turned away. */
|
|
129
|
+
accept(raw, address = '') {
|
|
130
|
+
const now = this.#now();
|
|
131
|
+
const recent = (this.#connects.get(address) ?? []).filter((t) => now - t < 60_000);
|
|
132
|
+
recent.push(now); this.#connects.set(address, recent);
|
|
133
|
+
const waiting = [...this.#lobby].filter((c) => c.address === address).length;
|
|
134
|
+
if (recent.length > CONNECTS_PER_MINUTE || this.#lobby.size >= LOBBY_MAX || waiting >= LOBBY_PER_ADDRESS) { try { raw.close(); } catch { /* gone */ } return false; }
|
|
135
|
+
const conn = { raw, address, since: now, code: null, tabs: null, handler: null, closers: new Set(), taken: false };
|
|
136
|
+
this.#lobby.add(conn);
|
|
137
|
+
raw.onMessage((msg, size = 0) => {
|
|
138
|
+
if (conn.handler) { conn.handler(msg); return; }
|
|
139
|
+
if (size > LOBBY_MESSAGE_MAX) { this.#drop(conn); return; }
|
|
140
|
+
this.#lobbyMessage(conn, msg);
|
|
141
|
+
});
|
|
142
|
+
raw.onClose(() => { this.#lobby.delete(conn); for (const fn of conn.closers) { try { fn(); } catch { /* its fault */ } } if (conn.code) this.#changed(); });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
#drop(conn) { this.#lobby.delete(conn); try { conn.raw.close(); } catch { /* gone */ } }
|
|
146
|
+
|
|
147
|
+
#lobbyMessage(conn, msg) {
|
|
148
|
+
if (!msg || typeof msg !== 'object') return;
|
|
149
|
+
if (msg.t === 'ping') { conn.raw.send({ t: 'pong' }); return; }
|
|
150
|
+
if (msg.t === 'tabs') { conn.tabs = msg; return; }
|
|
151
|
+
if (msg.t === 'pair') {
|
|
152
|
+
const code = typeof msg.code === 'string' ? msg.code.trim() : '';
|
|
153
|
+
if (/^[0-9A-Za-z-]{4,32}$/.test(code)) { conn.code = code; this.#changed(); }
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (msg.t === 'hello' && typeof msg.token === 'string' && msg.token) {
|
|
157
|
+
const h = hash(msg.token);
|
|
158
|
+
const all = this.#tokens.get();
|
|
159
|
+
const id = Object.keys(all).find((k) => (all[k] ?? []).includes(h));
|
|
160
|
+
if (id && this.#targets().some((t) => t.id === id)) { this.#hand(conn, id, [msg]); return; }
|
|
161
|
+
conn.raw.send({ t: 'pair-needed' });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* The socket leaves the lobby for its browser's connector, what it said replayed there. */
|
|
166
|
+
#hand(conn, id, said) {
|
|
167
|
+
this.#lobby.delete(conn);
|
|
168
|
+
conn.taken = true;
|
|
169
|
+
const channel = { send: (o) => conn.raw.send(o), onMessage: (fn) => { conn.handler = fn; }, onClose: (fn) => { conn.closers.add(fn); }, close: () => { try { conn.raw.close(); } catch { /* gone */ } } };
|
|
170
|
+
const i = this.#of(id);
|
|
171
|
+
i.accept(channel);
|
|
172
|
+
for (const m of said) conn.handler?.(m);
|
|
173
|
+
if (conn.tabs) conn.handler?.(conn.tabs);
|
|
174
|
+
return i;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Is a browser waiting with a code (the page says so while the person types it)? */
|
|
178
|
+
waiting() { return [...this.#lobby].filter((c) => c.code).length; }
|
|
179
|
+
|
|
180
|
+
/* THE PERSON TYPED THE CODE: { code, name?, target? } -> the target, made (or the same one, paired again). */
|
|
181
|
+
pair({ code, name = '', target = '' } = {}) {
|
|
182
|
+
const now = this.#now();
|
|
183
|
+
this.#wrong = this.#wrong.filter((t) => now - t < WRONG_WINDOW_MS);
|
|
184
|
+
if (this.#wrong.length >= WRONG_MAX) throw new Refusal(429, 'Too many wrong codes. Wait a few minutes.');
|
|
185
|
+
const given = String(code ?? '').replace(/\s+/g, '');
|
|
186
|
+
if (!given) throw new Refusal(400, 'Type the code the extension shows.');
|
|
187
|
+
const offered = [...this.#lobby].filter((c) => c.code === given);
|
|
188
|
+
if (offered.length !== 1) {
|
|
189
|
+
this.#wrong.push(now);
|
|
190
|
+
this.#log(`browser: a pairing code was refused (${offered.length ? 'offered twice' : 'unknown'})`);
|
|
191
|
+
/* Two sockets with one code: neither is trusted. */
|
|
192
|
+
for (const c of offered) this.#drop(c);
|
|
193
|
+
throw new Refusal(400, offered.length ? 'That code was offered twice. Open the popup again.' : 'No browser shows that code. Check it, and the address.');
|
|
194
|
+
}
|
|
195
|
+
const store = this.#store();
|
|
196
|
+
let t;
|
|
197
|
+
if (target) {
|
|
198
|
+
t = store.target(String(target));
|
|
199
|
+
if (!t || t.kind !== 'browser') throw new Refusal(404, 'No such browser.');
|
|
200
|
+
/* Paired again: its old tokens go, the old socket too. */
|
|
201
|
+
this.forget(t.id, { keepTarget: true });
|
|
202
|
+
} else {
|
|
203
|
+
const title = String(name ?? '').trim().slice(0, TITLE_MAX) || 'Browser';
|
|
204
|
+
const base = `browser-${slug(title) || 'browser'}`;
|
|
205
|
+
let id = base;
|
|
206
|
+
for (let n = 2; store.target(id); n++) id = `${base}-${n}`;
|
|
207
|
+
t = store.putTarget({ id, title, kind: 'browser', connector: 'browser', config: {} });
|
|
208
|
+
}
|
|
209
|
+
const [conn] = offered;
|
|
210
|
+
const i = this.#hand(conn, t.id, [{ t: 'pair', code: given }]);
|
|
211
|
+
i.pair(given);
|
|
212
|
+
this.#log(`browser: ${t.id} paired`);
|
|
213
|
+
this.#changed();
|
|
214
|
+
return { id: t.id, title: t.title, kind: 'browser' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* THE SITES A CONVERSATION MAY ACT ON WITHOUT ASKING, at frame (« Encadrer »): Tether's site rules on its grant,
|
|
218
|
+
replaced by this list (one host each, *.host allowed). Empty: nothing runs freely. -> the list kept. */
|
|
219
|
+
async setSites(tethers, { target, agent, sites } = {}) {
|
|
220
|
+
const t = this.#store().target(String(target ?? ''));
|
|
221
|
+
if (!t || t.kind !== 'browser') throw new Refusal(404, 'No such browser.');
|
|
222
|
+
const who = String(agent ?? '');
|
|
223
|
+
if (!/^conversation:./.test(who)) throw new Refusal(400, 'Sites are given to a conversation.');
|
|
224
|
+
const g = t.grants?.[who];
|
|
225
|
+
if (!g) throw new Refusal(400, 'Give it the browser first.');
|
|
226
|
+
const given = (Array.isArray(sites) ? sites : String(sites ?? '').split(/[\n,]+/)).map((x) => String(x).trim()).filter(Boolean);
|
|
227
|
+
if (given.length > SITES_MAX) throw new Refusal(400, `${SITES_MAX} sites at most.`);
|
|
228
|
+
const want = [];
|
|
229
|
+
for (const x of given) {
|
|
230
|
+
const v = x.toLowerCase().replace(/^https?:\/\//, '').replace(/[/?#].*$/, '').replace(/:\d+$/, '').replace(/\.$/, '');
|
|
231
|
+
if (!/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*$/.test(v)) throw new Refusal(400, `Not a site: ${x.slice(0, 80)}. A host, or *.host.`);
|
|
232
|
+
if (!want.includes(v)) want.push(v);
|
|
233
|
+
}
|
|
234
|
+
const had = (g.rules ?? []).filter((r) => r.kind === 'site').map((r) => r.value);
|
|
235
|
+
for (const v of had) if (!want.includes(v)) { const r = await tethers.act('unallow', { target: t.id, agent: who, rule: { kind: 'site', value: v } }, ''); if (!r.ok) throw new Refusal(400, r.text); }
|
|
236
|
+
for (const v of want) if (!had.includes(v)) { const r = await tethers.act('allow', { target: t.id, agent: who, rule: { kind: 'site', value: v } }, ''); if (!r.ok) throw new Refusal(400, r.text); }
|
|
237
|
+
return { sites: (this.#store().target(t.id).grants?.[who]?.rules ?? []).filter((r) => r.kind === 'site').map((r) => r.value) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Wrong codes counted now (the tests; the page says only that it must wait). */
|
|
241
|
+
wrongCodes() { const now = this.#now(); return this.#wrong.filter((t) => now - t < WRONG_WINDOW_MS).length; }
|
|
242
|
+
|
|
243
|
+
status(id) {
|
|
244
|
+
const s = this.#inst.get(id)?.status();
|
|
245
|
+
return { connected: !!s?.connected, tabs: (s?.tabs ?? []).map((x) => ({ id: x.id, title: x.title, url: x.url, site: x.site })) };
|
|
246
|
+
}
|
|
247
|
+
/* Every browser's state, for the page: { id: { connected, tabs } }. */
|
|
248
|
+
all() { return Object.fromEntries(this.#targets().map((t) => [t.id, this.status(t.id)])); }
|
|
249
|
+
|
|
250
|
+
/* A browser removed (or paired again): its tokens and its socket go. */
|
|
251
|
+
forget(id, { keepTarget = false } = {}) {
|
|
252
|
+
const all = this.#tokens.get();
|
|
253
|
+
if (all[id]) { delete all[id]; this.#tokens.set(all); }
|
|
254
|
+
const i = this.#inst.get(id);
|
|
255
|
+
if (i) { try { i.shutdown(); } catch { /* gone */ } this.#inst.delete(id); }
|
|
256
|
+
if (!keepTarget) this.#log(`browser: ${id} unpaired`);
|
|
257
|
+
this.#changed();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#sweep() {
|
|
261
|
+
const now = this.#now();
|
|
262
|
+
for (const c of [...this.#lobby]) if (now - c.since > LOBBY_MS) this.#drop(c);
|
|
263
|
+
for (const [a, list] of this.#connects) { const keep = list.filter((t) => now - t < 60_000); if (keep.length) this.#connects.set(a, keep); else this.#connects.delete(a); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* THE ENDPOINT on this server: a WebSocket at BROWSER_PATH. No sign-in is looked at (the front lets /c/ through
|
|
267
|
+
without one); the token said on the socket is the right. */
|
|
268
|
+
attach(server, WebSocketServer) {
|
|
269
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MESSAGE_MAX });
|
|
270
|
+
server.on('upgrade', (request, socket, head) => {
|
|
271
|
+
const { pathname } = new URL(request.url ?? '/', 'http://local');
|
|
272
|
+
if (pathname !== BROWSER_PATH) return;
|
|
273
|
+
const address = addressOf(request);
|
|
274
|
+
wss.handleUpgrade(request, socket, head, (ws) => this.accept(channelOf(ws), address));
|
|
275
|
+
});
|
|
276
|
+
return wss;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
shutdown() {
|
|
280
|
+
clearInterval(this.#timer);
|
|
281
|
+
for (const i of this.#inst.values()) { try { i.shutdown(); } catch { /* gone */ } }
|
|
282
|
+
this.#inst.clear();
|
|
283
|
+
for (const c of [...this.#lobby]) this.#drop(c);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* A `ws` socket as a channel: JSON each way, each message's size given with it. */
|
|
288
|
+
export function channelOf(ws) {
|
|
289
|
+
return {
|
|
290
|
+
send: (obj) => { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); },
|
|
291
|
+
onMessage: (fn) => ws.on('message', (data, binary) => { if (binary) return; let m; try { m = JSON.parse(String(data)); } catch { return; } fn(m, data.length ?? 0); }),
|
|
292
|
+
onClose: (fn) => ws.on('close', () => fn()),
|
|
293
|
+
close: () => { try { ws.close(); } catch { /* gone */ } },
|
|
294
|
+
};
|
|
295
|
+
}
|
package/server/demandes.mjs
CHANGED
|
@@ -60,6 +60,55 @@ function mcpCard(c, v, sub) {
|
|
|
60
60
|
v.irreversible = cls === 'destructive';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/* AN ACT IN A PERSON'S BROWSER (Tether's card { kind: 'browser', action, url?, selector?, text?, key? }, and what the
|
|
64
|
+
* browser connector adds in card.about: the tab by its title and address, the script, the point, the drag; browsers.mjs).
|
|
65
|
+
* Which tab, on which site, and what will be done there; a script shown whole, said to run with the page's own rights
|
|
66
|
+
* (irreversible: never in Accept all). Parley has words only for Open: the others carry their English (`t`). */
|
|
67
|
+
const BROWSER_SAID = {
|
|
68
|
+
click: ['req-browser-click', 'Click in the tab {tab}', 'click'],
|
|
69
|
+
type: ['req-browser-type', 'Type in the tab {tab}', 'type'],
|
|
70
|
+
key: ['req-browser-key', 'Press {key} in the tab {tab}', 'press'],
|
|
71
|
+
scroll: ['req-browser-scroll', 'Scroll in the tab {tab}', 'scroll'],
|
|
72
|
+
drag: ['req-browser-drag', 'Drag in the tab {tab}', 'drag'],
|
|
73
|
+
eval: ['req-browser-eval', 'Run a script in {tab}', 'run'],
|
|
74
|
+
};
|
|
75
|
+
/* Parley says Run and Type; the others, here. */
|
|
76
|
+
const YES_SAID = { click: 'Click', press: 'Press', scroll: 'Scroll', drag: 'Drag' };
|
|
77
|
+
function browserCard(c, about, v, sub, where) {
|
|
78
|
+
const tab = about.tab ?? null;
|
|
79
|
+
const site = String(tab?.site || (() => { try { return new URL(tab?.url ?? '').hostname; } catch { return ''; } })());
|
|
80
|
+
const tabName = tab ? String(tab.title || site || tab.url || '').slice(0, 80) : '';
|
|
81
|
+
const it = tabName ? `« ${tabName} »` : '';
|
|
82
|
+
if (c.action === 'open') {
|
|
83
|
+
v.title = w('req-browser-open', { url: String(c.url ?? '') }); v.yes = 'open';
|
|
84
|
+
if (where) sub.push(where);
|
|
85
|
+
if (tab) v.facts.push([said('req-fact-tab', 'Tab'), tabName]);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const [k, t, yes] = BROWSER_SAID[c.action] ?? ['req-browser-act', 'Act in the tab {tab}', 'accept'];
|
|
89
|
+
const key = String(c.key ?? about.key ?? '');
|
|
90
|
+
/* No tab named: the one the AI is in (the extension's last), said so. */
|
|
91
|
+
const plain = it ? t : t.replace(/(the tab )?\{tab\}/, 'the active tab');
|
|
92
|
+
v.title = said(it ? k : `${k}-active`, plain.replace('{key}', key).replace('{tab}', it), it || c.action === 'key' ? { ...(it ? { tab: it } : {}), ...(c.action === 'key' ? { key } : {}) } : undefined);
|
|
93
|
+
v.yes = yes;
|
|
94
|
+
if (yes in YES_SAID) v.yesSaid = said(`req-yes-${yes}`, YES_SAID[yes]);
|
|
95
|
+
if (site) sub.push(site);
|
|
96
|
+
if (where) sub.push(where);
|
|
97
|
+
if (tab?.url) v.facts.push([said('req-fact-page', 'Page'), String(tab.url)]);
|
|
98
|
+
const selector = String(c.selector ?? about.selector ?? '');
|
|
99
|
+
if (selector) v.facts.push([said('req-fact-element', 'Element'), selector]);
|
|
100
|
+
if (c.action === 'click' && !selector && about.x !== undefined) v.facts.push([said('req-fact-point', 'Point'), `${about.x}, ${about.y ?? 0}`]);
|
|
101
|
+
if (c.action === 'scroll') v.facts.push([said('req-fact-by', 'By'), `${about.dx ?? 0}, ${about.dy ?? 0}`]);
|
|
102
|
+
if (c.action === 'drag') { v.before = [String(about.from ?? '')]; v.after = [String(about.to ?? '')]; }
|
|
103
|
+
if (c.action === 'type' && c.text !== undefined) v.quote = String(c.text);
|
|
104
|
+
if (c.action === 'eval') {
|
|
105
|
+
const script = String(about.script ?? '');
|
|
106
|
+
if (script) v.code = script;
|
|
107
|
+
v.risk.push(said('req-risk-script', 'Has this page\'s rights: it can do anything there'));
|
|
108
|
+
v.irreversible = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
63
112
|
export function demandeView(q) {
|
|
64
113
|
const c = q?.card ?? {}; const about = c.about ?? {};
|
|
65
114
|
const kind = KINDS.has(c.kind) ? c.kind : 'other';
|
|
@@ -179,7 +228,7 @@ export function demandeView(q) {
|
|
|
179
228
|
else if (c.path) v.facts.push(fact('path', String(c.path)));
|
|
180
229
|
v.yes = c.action === 'delete' ? 'delete' : c.action === 'move' ? 'move' : 'write';
|
|
181
230
|
if (c.action === 'delete') { v.irreversible = true; v.risk.push(w('req-risk-final')); }
|
|
182
|
-
} else if (c.kind === 'browser'
|
|
231
|
+
} else if (c.kind === 'browser') browserCard(c, about, v, sub, where);
|
|
183
232
|
else if (c.kind === 'mcp') mcpCard(c, v, sub);
|
|
184
233
|
else { v.title = `${q?.verb ?? 'action'}${q?.detail ? ` ${q.detail}` : ''}`; if (where) sub.push(where); }
|
|
185
234
|
const id = String(q?.id ?? '');
|
|
@@ -192,7 +241,7 @@ export function demandeView(q) {
|
|
|
192
241
|
/* The technical line, in "See all" only. */
|
|
193
242
|
detail: [q?.verb, q?.detail].filter(Boolean).join(' — '),
|
|
194
243
|
irreversible: v.irreversible,
|
|
195
|
-
yes: { id: `allow:${id}`, label: w(`req-yes-${v.yes}`) }, no: { id: `deny:${id}`, label: w('req-no') },
|
|
244
|
+
yes: { id: `allow:${id}`, label: v.yesSaid ?? w(`req-yes-${v.yes}`) }, no: { id: `deny:${id}`, label: w('req-no') },
|
|
196
245
|
/* "Always allow ...": behind "⋯", as before. */
|
|
197
246
|
...(q?.rule ? { others: [{ id: `always:${id}`, label: q.rule.value ? w('req-always', { what: String(q.rule.value) }) : w('req-always-this') }] } : {}),
|
|
198
247
|
hold: HOLD_S,
|
package/server/main.mjs
CHANGED
|
@@ -73,6 +73,7 @@ import { Wifi } from './wifi.mjs';
|
|
|
73
73
|
import { MeshCentral } from './meshcentral.mjs';
|
|
74
74
|
import { Binders } from './binders.mjs';
|
|
75
75
|
import { Tethers } from './tethers.mjs';
|
|
76
|
+
import { Browsers } from './browsers.mjs';
|
|
76
77
|
import { Accounts } from './accounts.mjs';
|
|
77
78
|
import { ServerLogs } from './serverlogs.mjs';
|
|
78
79
|
import { History, STEP_MS as HISTORY_STEP_MS } from './history.mjs';
|
|
@@ -475,7 +476,10 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
475
476
|
mailbox, a calendar, an address book -- held here with their secrets read from
|
|
476
477
|
the vault by name, never in a machine: see tethers.mjs. */
|
|
477
478
|
/* Voice boxes are Tether targets too (boxes.mjs): their connector reaches them through `boxes`, made below. */
|
|
478
|
-
|
|
479
|
+
/* A person's own browser, paired through the Tether extension, which connects out to this server (browsers.mjs). */
|
|
480
|
+
let tethers = null;
|
|
481
|
+
const browsers = new Browsers({ dataDir, store: () => tethers?.store(), log: (line) => console.log(line) });
|
|
482
|
+
tethers = new Tethers({ dataDir, resources: folders, conversations: () => all.list().map((c) => c.name), secrets, dockets, browsers, connectors: { voicebox: { run: (...a) => voiceboxConnector(boxes).run(...a) }, ...(tetherConnectors ?? {}) } });
|
|
479
483
|
const stopTethers = tethers.start();
|
|
480
484
|
/* Connecting a mailbox, calendar or address book by token: the consent happens
|
|
481
485
|
in the owner's browser, the refresh token is kept host-only (never in a
|
|
@@ -794,6 +798,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
794
798
|
server.on('close', () => { closing = true; stopBinders(); stopTethers(); clearInterval(historyTimer); clearInterval(secretsTimer); history.save(); stopVmWatch(); stopShelf(); stopHealth(); stopNetworkWatch(); stopUpdates(); stopWatch(); clearInterval(mailTimer); clearInterval(restartTimer); clearInterval(expiryTimer); clearInterval(answerTimer); clearInterval(wakeTimer); clearInterval(docketTimer); dockets.stop(); clearInterval(gitTimer); talk.stop(); });
|
|
795
799
|
let closing = false;
|
|
796
800
|
boxes.attach(server, WebSocketServer);
|
|
801
|
+
browsers.attach(server, WebSocketServer); server.on('close', () => browsers.shutdown());
|
|
797
802
|
/* The wake word's weekly threshold tuning (Sunday from 03:00, each box's own time): looked at every 10 minutes. */
|
|
798
803
|
const wakeWeekly = setInterval(() => { try { boxes.wakeWeekly(); } catch (error) { console.error(`wake tuning: ${error.message}`); } }, 10 * 60_000); wakeWeekly.unref?.(); server.on('close', () => clearInterval(wakeWeekly));
|
|
799
804
|
/* A collect session's clock: the stations asked, its end, the speaker told to stop (boxcollect.mjs). */
|
package/server/settingswords.mjs
CHANGED
|
@@ -171,6 +171,26 @@ export const SETTINGS_WORDS_FR = {
|
|
|
171
171
|
'set-rt-mcp-hint-none': 'Pas donné', 'set-rt-mcp-hint-watch': 'Ses outils en lecture seule',
|
|
172
172
|
'set-rt-mcp-hint-propose': 'Tous les outils, chaque modification approuvée', 'set-rt-mcp-hint-free': 'Tous les outils ; suppressions demandées',
|
|
173
173
|
'set-rt-mcp-hint-frame': 'Votre choix pour chaque sorte d\'outil',
|
|
174
|
+
/* A browser paired through the Tether extension (browsers.mjs): the form, its panel, Frame's sites. */
|
|
175
|
+
'set-na-k-browser': 'Navigateur', 'set-br-head': 'Associer un navigateur',
|
|
176
|
+
'set-br-step-install': 'Installez l\'extension Tether.', 'set-br-step-address': 'Dans sa fenêtre, mettez l\'adresse ci-dessous.',
|
|
177
|
+
'set-br-step-code': 'Tapez ici le code qu\'elle affiche.', 'set-br-address': 'Adresse pour l\'extension', 'set-br-copy': 'Copier', 'set-br-copied': 'Copié',
|
|
178
|
+
'set-br-name': 'Nom', 'set-br-code': 'Code', 'set-br-pair': 'Associer', 'set-br-pair-again': 'Associer à nouveau', 'set-br-pairing': 'Association',
|
|
179
|
+
'set-br-connected': 'Connecté · {n} onglet(s) donné(s)', 'set-br-offline': 'Non connecté : ouvrez ce navigateur',
|
|
180
|
+
'set-br-tabs': 'Onglets', 'set-br-no-tabs': 'Aucun onglet donné : voyez sa fenêtre.',
|
|
181
|
+
'set-br-frame': 'Encadrer : sites', 'set-br-sites': 'Sites autorisés (un par ligne, *.exemple.fr)',
|
|
182
|
+
'set-br-sites-hint': 'Autres sites : demandé à chaque fois.', 'set-br-save': 'Enregistrer', 'set-br-saved': 'Enregistré.',
|
|
183
|
+
/* Its approval cards (demandes.mjs), said by the chat screen too. */
|
|
184
|
+
'req-browser-click': 'Cliquer dans l\'onglet {tab}', 'req-browser-click-active': 'Cliquer dans l\'onglet actif',
|
|
185
|
+
'req-browser-type': 'Écrire dans l\'onglet {tab}', 'req-browser-type-active': 'Écrire dans l\'onglet actif',
|
|
186
|
+
'req-browser-key': 'Appuyer sur {key} dans l\'onglet {tab}', 'req-browser-key-active': 'Appuyer sur {key} dans l\'onglet actif',
|
|
187
|
+
'req-browser-scroll': 'Défiler dans l\'onglet {tab}', 'req-browser-scroll-active': 'Défiler dans l\'onglet actif',
|
|
188
|
+
'req-browser-drag': 'Glisser dans l\'onglet {tab}', 'req-browser-drag-active': 'Glisser dans l\'onglet actif',
|
|
189
|
+
'req-browser-eval': 'Exécuter un script dans {tab}', 'req-browser-eval-active': 'Exécuter un script dans l\'onglet actif',
|
|
190
|
+
'req-browser-act': 'Agir dans l\'onglet {tab}', 'req-browser-act-active': 'Agir dans l\'onglet actif',
|
|
191
|
+
'req-risk-script': 'Droits de la page : il peut tout y faire',
|
|
192
|
+
'req-fact-tab': 'Onglet', 'req-fact-page': 'Page', 'req-fact-element': 'Élément', 'req-fact-point': 'Point', 'req-fact-by': 'De',
|
|
193
|
+
'req-yes-click': 'Cliquer', 'req-yes-press': 'Appuyer', 'req-yes-scroll': 'Défiler', 'req-yes-drag': 'Glisser',
|
|
174
194
|
'set-cx-ssh': 'machine', 'set-cx-computer': 'ordinateur', 'set-cx-browser': 'navigateur', 'set-cx-screen': 'écran', 'set-cx-device': 'appareil',
|
|
175
195
|
'set-acc-share': 'Partager avec un appareil…', 'set-acc-in-assets': 'Ouvrir dans Assets',
|
|
176
196
|
'set-acc-used-1': 'Utilisé par 1 conversation : mettez « Aucun »', 'set-acc-used-n': 'Utilisé par {n} conversations : mettez « Aucun »',
|
package/server/tethers.mjs
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* that must be decided waits, and the person decides it.
|
|
17
17
|
*
|
|
18
18
|
* The SSH and online-folder connectors are wired by the keeper itself (each a client that holds
|
|
19
|
-
* nothing until a target asks it to connect). The
|
|
20
|
-
*
|
|
19
|
+
* nothing until a target asks it to connect). The browser connector needs a paired socket: the
|
|
20
|
+
* extension connects out to this server (browsers.mjs, `browsers`), each paired browser a target of
|
|
21
|
+
* its own. The screen connector, which needs an engine, is wired in a later step.
|
|
21
22
|
*/
|
|
22
23
|
import { mkdirSync, lstatSync } from 'node:fs';
|
|
23
24
|
import { keeper } from 'clavarium-tether/host';
|
|
@@ -110,7 +111,10 @@ export class Tethers {
|
|
|
110
111
|
/* `resources`: Resources (workFolder). `conversations()`: the names of the conversations.
|
|
111
112
|
`secrets`: the vault, read by name for a connector only (reveal). `dockets`: { on(name), post(name,
|
|
112
113
|
{kind, text, data}) } (dockets.mjs): what wakes an agent told of a notice. `make`: the keeper (tests). */
|
|
113
|
-
|
|
114
|
+
/* `browsers`: the paired browsers (browsers.mjs), set by the constructor when it is given one; their connector is the keeper's `browser`. */
|
|
115
|
+
browsers = null;
|
|
116
|
+
constructor({ dataDir, resources, conversations, secrets, connectors, browsers = null, dockets = null, now = () => Date.now(), make = keeper }) {
|
|
117
|
+
if (browsers) { connectors = { ...(connectors ?? {}), browser: browsers.connector }; this.browsers = browsers; }
|
|
114
118
|
this.#resources = resources; this.#conversations = conversations; this.#secrets = secrets; this.#dockets = dockets;
|
|
115
119
|
this.#offered = new JsonFile(`${dataDir}/tether-offered.json`, []);
|
|
116
120
|
this.#noticed = new JsonFile(`${dataDir}/tether-notices-seen.json`, []);
|
|
@@ -127,6 +131,8 @@ export class Tethers {
|
|
|
127
131
|
when it ends (ok, or its error). */
|
|
128
132
|
if (what?.journal) this.#tellResult(what.journal);
|
|
129
133
|
});
|
|
134
|
+
/* A browser connected, gone, or its granted tabs changed: the page reads again. */
|
|
135
|
+
browsers?.onChange(() => { this.#changes++; });
|
|
130
136
|
/* A device plugged into the owner's computer: one target, always there, flashed from the
|
|
131
137
|
owner's browser (Tether's device connector). The owner gives it to a conversation as any other. */
|
|
132
138
|
try {
|
package/web/app.js
CHANGED
|
@@ -3853,7 +3853,7 @@
|
|
|
3853
3853
|
line.append(summary, el('pre', '', rest));
|
|
3854
3854
|
return line;
|
|
3855
3855
|
}
|
|
3856
|
-
const kindIcon = (kind) => { const name = kind === 'send' || kind === 'demandes' ? 'arrow-up' : kind === 'box' ? 'mic' : kind === 'device' ? 'smartphone' : kind === 'mcp' ? 'link' : ASSET_KINDS.find((k) => k[0] === kind)?.[2]; const holder = el('span', 'as-ic'); holder.dataset.kind = kind; const svg = kind === 'list' ? listIcon() : name ? window.clavariumIcons?.icon(name) : null; if (svg) holder.append(svg); return holder; };
|
|
3856
|
+
const kindIcon = (kind) => { const name = kind === 'send' || kind === 'demandes' ? 'arrow-up' : kind === 'box' ? 'mic' : kind === 'device' ? 'smartphone' : kind === 'mcp' ? 'link' : kind === 'browser' ? 'globe' : ASSET_KINDS.find((k) => k[0] === kind)?.[2]; const holder = el('span', 'as-ic'); holder.dataset.kind = kind; const svg = kind === 'list' ? listIcon() : name ? window.clavariumIcons?.icon(name) : null; if (svg) holder.append(svg); return holder; };
|
|
3857
3857
|
/* A list (Binder's): the set has no such sign, so it is drawn here, in its style. */
|
|
3858
3858
|
function listIcon() {
|
|
3859
3859
|
const SVG = 'http://www.w3.org/2000/svg';
|
|
@@ -5025,7 +5025,7 @@
|
|
|
5025
5025
|
const newKinds = () => [['folder', uiSaid('set-na-k-folder', 'Folder')], ['git', uiSaid('set-na-k-git', 'Git repository')], ['mailbox', uiSaid('set-na-k-mailbox', 'Mailbox')],
|
|
5026
5026
|
['agenda', uiSaid('set-na-k-agenda', 'Calendar')], ['contacts', uiSaid('set-na-k-contacts', 'Contacts')], ['secret', uiSaid('set-na-k-secret', 'Secret')], ['list', uiSaid('set-na-k-list', LISTS_WORD.one)],
|
|
5027
5027
|
/* Step 4: a voice box (paired with a code) and a device (the share sheet, its code) are two kinds of it too. */
|
|
5028
|
-
['box', uiSaid('set-na-k-box', 'Voice box')], ['device', uiSaid('set-na-k-device', 'Device')], ['mcp', uiSaid('set-na-k-mcp', 'MCP server')]]
|
|
5028
|
+
['box', uiSaid('set-na-k-box', 'Voice box')], ['device', uiSaid('set-na-k-device', 'Device')], ['mcp', uiSaid('set-na-k-mcp', 'MCP server')], ['browser', uiSaid('set-na-k-browser', 'Browser')]]
|
|
5029
5029
|
.map(([kind, label]) => ({ kind, label, icon: kindIcon(kind) }));
|
|
5030
5030
|
/* A voice box's steps after its kind (the flow: "A NEW VOICE BOX", with the boxes), and the one under way. */
|
|
5031
5031
|
const BOX_STEPS = ['where', 'plug', 'pair'];
|
|
@@ -5056,6 +5056,7 @@
|
|
|
5056
5056
|
const form = $(`${addForm(kind)}-box`); form.hidden = false;
|
|
5057
5057
|
if (kind === 'git') { fillKeyChoice(); showVia(); }
|
|
5058
5058
|
if (kind === 'list') void fillTemplates();
|
|
5059
|
+
if (kind === 'browser') $('browser-address').value = browserAddress();
|
|
5059
5060
|
return form;
|
|
5060
5061
|
},
|
|
5061
5062
|
});
|
|
@@ -5190,6 +5191,59 @@
|
|
|
5190
5191
|
}
|
|
5191
5192
|
return out;
|
|
5192
5193
|
}
|
|
5194
|
+
/* A BROWSER PAIRED THROUGH THE TETHER EXTENSION (browsers.mjs): connected or not, the tabs its person granted (all an
|
|
5195
|
+
AI can touch), the address the extension connects to, « Pair again »; then, for each conversation at Frame, the
|
|
5196
|
+
sites it acts on without asking. */
|
|
5197
|
+
const browserAddress = () => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/c/api/browser`;
|
|
5198
|
+
function browserParts(t, tether = {}) {
|
|
5199
|
+
const out = [];
|
|
5200
|
+
const now = tether.browsers?.[t.id] ?? { connected: false, tabs: [] };
|
|
5201
|
+
const state = el('p', now.connected ? 'hint br-on' : 'hint br-off', now.connected
|
|
5202
|
+
? uiSaid('set-br-connected', 'Connected · {n} tab(s) granted', { n: now.tabs.length }) : uiSaid('set-br-offline', 'Not connected: open that browser'));
|
|
5203
|
+
state.dataset.act = 'browser-state';
|
|
5204
|
+
const list = el('ul', 'br-tabs');
|
|
5205
|
+
for (const x of now.tabs) { const li = el('li', '', x.title || x.url || String(x.id)); li.title = x.url ?? ''; list.append(li); }
|
|
5206
|
+
const body = [state];
|
|
5207
|
+
body.push(now.tabs.length ? list : el('p', 'hint', uiSaid('set-br-no-tabs', 'No tab granted: grant them in its popup.')));
|
|
5208
|
+
out.push({ title: uiSaid('set-br-tabs', 'Tabs'), body, cls: 'as-br-tabs' });
|
|
5209
|
+
/* Paired again (the extension reinstalled, its pairing reset): its new code, the same asset. */
|
|
5210
|
+
const again = el('div', 'br-again');
|
|
5211
|
+
const code = el('input'); code.inputMode = 'numeric'; code.autocomplete = 'off'; code.placeholder = uiSaid('set-br-code', 'Code'); code.setAttribute('aria-label', uiSaid('set-br-code', 'Code'));
|
|
5212
|
+
const go = el('button', '', uiSaid('set-br-pair-again', 'Pair again')); go.type = 'button'; go.dataset.act = 'browser-pair-again';
|
|
5213
|
+
go.addEventListener('click', async () => {
|
|
5214
|
+
go.disabled = true; $(asErrId()).hidden = true;
|
|
5215
|
+
try { await post(`${API}/tether/browser-pair`, { code: code.value.trim(), target: t.id }); } catch (error) { sayAsErr(error); }
|
|
5216
|
+
go.disabled = false; await repaintAsset();
|
|
5217
|
+
});
|
|
5218
|
+
again.append(el('p', 'hint br-address', browserAddress()), code, go);
|
|
5219
|
+
out.push({ title: uiSaid('set-br-pairing', 'Pairing'), body: [again], cls: 'as-br-pair' });
|
|
5220
|
+
/* Frame: the sites each conversation acts on without a card (Tether's site rules). Empty: everything asked. */
|
|
5221
|
+
const framed = Object.entries(t.grants ?? {}).filter(([id, g]) => id.startsWith('conversation:') && g?.level === 'frame');
|
|
5222
|
+
if (framed.length) {
|
|
5223
|
+
const rows = framed.map(([id, g]) => {
|
|
5224
|
+
const name = id.slice('conversation:'.length);
|
|
5225
|
+
const box = el('div', 'br-sites'); box.dataset.conversation = name;
|
|
5226
|
+
const who = conversations.find((c) => c.name === name);
|
|
5227
|
+
const lab = el('label', '', uiSaid('set-br-sites', 'Allowed sites (one per line, *.example.com)'));
|
|
5228
|
+
const ta = el('textarea'); ta.rows = 3; ta.spellcheck = false; ta.autocapitalize = 'off'; ta.placeholder = 'shop.example.com\n*.example.org';
|
|
5229
|
+
ta.value = (g.rules ?? []).filter((r) => r.kind === 'site').map((r) => r.value).join('\n');
|
|
5230
|
+
const id2 = `br-sites-${t.id}-${name}`.replace(/[^a-zA-Z0-9-]/g, '-'); ta.id = id2; lab.htmlFor = id2;
|
|
5231
|
+
const save = el('button', '', uiSaid('set-br-save', 'Save')); save.type = 'button'; save.dataset.act = 'browser-sites';
|
|
5232
|
+
const said = el('small', 'hint');
|
|
5233
|
+
save.addEventListener('click', async () => {
|
|
5234
|
+
save.disabled = true; $(asErrId()).hidden = true;
|
|
5235
|
+
try { const r = await post(`${API}/tether/browser-sites`, { target: t.id, agent: id, sites: ta.value.split('\n') }); ta.value = (r.sites ?? []).join('\n'); said.textContent = uiSaid('set-br-saved', 'Saved.'); }
|
|
5236
|
+
catch (error) { sayAsErr(error); }
|
|
5237
|
+
save.disabled = false;
|
|
5238
|
+
});
|
|
5239
|
+
const row = el('div', 'row'); row.append(save, said);
|
|
5240
|
+
box.append(el('b', '', (who && shownName(who)) || name), lab, ta, el('p', 'hint', uiSaid('set-br-sites-hint', 'Other sites: asked each time.')), row);
|
|
5241
|
+
return box;
|
|
5242
|
+
});
|
|
5243
|
+
out.push({ title: uiSaid('set-br-frame', 'Frame: sites'), body: rows, cls: 'as-br-frame' });
|
|
5244
|
+
}
|
|
5245
|
+
return out;
|
|
5246
|
+
}
|
|
5193
5247
|
/* `detail`: where it is drawn. `from`: the conversation it is seen from ('' : the menu). `back`: the way back (a phone).
|
|
5194
5248
|
`tether`, `groups`: as the page read them. `gits`: the conversation's repositories (To send, beside). */
|
|
5195
5249
|
async function assetPanel(detail, chosen, { from = '', back, tether = {}, groups = [], gits = [], stale = () => false } = {}) {
|
|
@@ -5276,6 +5330,7 @@
|
|
|
5276
5330
|
parts.push(box);
|
|
5277
5331
|
}
|
|
5278
5332
|
if (t.kind === 'mcp') parts.push(...mcpParts(t));
|
|
5333
|
+
if (t.kind === 'browser') parts.push(...browserParts(t, tether));
|
|
5279
5334
|
if (chosen.siblings?.length) {
|
|
5280
5335
|
const a = el('p', 'as-assoc hint');
|
|
5281
5336
|
a.append(el('b', '', 'Linked'), document.createTextNode(` to the same account: ${chosen.siblings.join(', ')}. They share the account's sign-in (kept on the server); rights are given separately.`));
|
|
@@ -8886,6 +8941,24 @@
|
|
|
8886
8941
|
btn.disabled = false;
|
|
8887
8942
|
await paintAssets();
|
|
8888
8943
|
});
|
|
8944
|
+
/* PAIR A BROWSER (browsers.mjs): the code the Tether extension's popup shows, once; it reconnects by itself after. */
|
|
8945
|
+
$('browser-new').addEventListener('submit', async (event) => {
|
|
8946
|
+
event.preventDefault();
|
|
8947
|
+
$('browser-error').hidden = true;
|
|
8948
|
+
const btn = $('browser-pair'); btn.disabled = true;
|
|
8949
|
+
try {
|
|
8950
|
+
const made = await post(`${API}/tether/browser-pair`, { name: $('browser-name').value.trim(), code: $('browser-code').value.trim() });
|
|
8951
|
+
$('browser-new').reset();
|
|
8952
|
+
await assetMade($('browser-box'), { key: `connexion:${made.id}`, name: made.title, give: (to, level) => grantTarget(made.id, to, targetLevel(level)) });
|
|
8953
|
+
} catch (error) { $('browser-error').textContent = error.message; $('browser-error').hidden = false; }
|
|
8954
|
+
btn.disabled = false;
|
|
8955
|
+
await paintAssets();
|
|
8956
|
+
});
|
|
8957
|
+
$('browser-address').value = browserAddress();
|
|
8958
|
+
$('browser-copy').addEventListener('click', async () => {
|
|
8959
|
+
try { await navigator.clipboard.writeText($('browser-address').value); $('browser-copy').textContent = uiSaid('set-br-copied', 'Copied'); }
|
|
8960
|
+
catch { $('browser-address').select(); }
|
|
8961
|
+
});
|
|
8889
8962
|
$('mcp-import').addEventListener('click', async () => {
|
|
8890
8963
|
$('mcp-error').hidden = true;
|
|
8891
8964
|
const btn = $('mcp-import'); btn.disabled = true;
|
package/web/index.html
CHANGED
|
@@ -882,6 +882,19 @@
|
|
|
882
882
|
<p id="mcp-error" class="error" role="alert" hidden></p>
|
|
883
883
|
</form>
|
|
884
884
|
</div>
|
|
885
|
+
<div id="browser-box" class="as-form" data-kind="browser"><h3 data-said="set-br-head">Pair a browser</h3>
|
|
886
|
+
<ol class="hint br-steps"><li data-said="set-br-step-install">Install the Tether extension.</li><li data-said="set-br-step-address">In its popup, set the address below.</li><li data-said="set-br-step-code">Type here the code it shows.</li></ol>
|
|
887
|
+
<form id="browser-new" autocomplete="off">
|
|
888
|
+
<label for="browser-address" data-said="set-br-address">Address for the extension</label>
|
|
889
|
+
<div class="row"><input id="browser-address" readonly spellcheck="false"><button type="button" id="browser-copy" data-said="set-br-copy">Copy</button></div>
|
|
890
|
+
<label for="browser-name" data-said="set-br-name">Name</label>
|
|
891
|
+
<input id="browser-name" maxlength="80" spellcheck="false" placeholder="Laptop · Firefox">
|
|
892
|
+
<label for="browser-code" data-said="set-br-code">Code</label>
|
|
893
|
+
<input id="browser-code" inputmode="numeric" autocomplete="off" maxlength="32" placeholder="123456" required>
|
|
894
|
+
<div class="row"><button type="submit" id="browser-pair" class="primary" data-said="set-br-pair">Pair</button></div>
|
|
895
|
+
<p id="browser-error" class="error" role="alert" hidden></p>
|
|
896
|
+
</form>
|
|
897
|
+
</div>
|
|
885
898
|
<div id="list-box" class="as-form" data-kind="list"><h3 id="listnew-head">New binder</h3>
|
|
886
899
|
<form id="list-new" autocomplete="off">
|
|
887
900
|
<label for="listnew-template">Template</label>
|
package/web/style.css
CHANGED
|
@@ -2171,6 +2171,16 @@ details.commit > pre { margin: 6px 0 0 1.6em; white-space: pre-wrap; overflow-wr
|
|
|
2171
2171
|
.mcp-custom { display: flex; flex-direction: column; gap: 4px; margin: .4rem 0 .8rem; }
|
|
2172
2172
|
.mcp-custom-line { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; }
|
|
2173
2173
|
#mcp-config { width: 100%; font: .8125rem var(--mono, monospace); }
|
|
2174
|
+
/* A paired browser (app.js browserParts): its granted tabs, the address, Frame's sites per conversation. */
|
|
2175
|
+
.br-steps { margin: .2rem 0 .6rem; padding-left: 1.3rem; }
|
|
2176
|
+
#browser-address, .br-address { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--mono, monospace); }
|
|
2177
|
+
.br-tabs { list-style: none; margin: .3rem 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
2178
|
+
.br-tabs li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 0; border-bottom: 1px solid var(--line); }
|
|
2179
|
+
.br-on { color: var(--ok, inherit); }
|
|
2180
|
+
.br-again { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
2181
|
+
.br-again input { width: 8em; }
|
|
2182
|
+
.br-sites { display: flex; flex-direction: column; gap: 4px; margin: .4rem 0 .8rem; }
|
|
2183
|
+
.br-sites textarea { width: 100%; font: .8125rem var(--mono, monospace); }
|
|
2174
2184
|
.as-drag-ghost { position: fixed; z-index: 90; pointer-events: none; opacity: .9; box-shadow: 0 12px 30px -10px rgba(0,0,0,.5); border-radius: 10px; background: var(--panel); }
|
|
2175
2185
|
.kit-aslist-group.as-drop-over { outline: 2px dashed var(--brass); outline-offset: 2px; border-radius: 10px; background: color-mix(in srgb, var(--brass) 7%, transparent); }
|
|
2176
2186
|
body.as-dragging { user-select: none; -webkit-user-select: none; }
|