tryharness 0.7.22 → 0.7.25
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 +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/phones.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
b64u,
|
|
5
|
+
channelKey,
|
|
6
|
+
constantTimeEqual,
|
|
7
|
+
loadOrCreateIdentity,
|
|
8
|
+
MAX_SKEW_MS,
|
|
9
|
+
open,
|
|
10
|
+
pairProof,
|
|
11
|
+
seal,
|
|
12
|
+
} from "./sealed.mjs";
|
|
4
13
|
|
|
5
14
|
// The daemon's side of the person's own devices: pairing offers made on this machine (a QR code
|
|
6
15
|
// for a phone, a link opened in a browser), and sealed calls from paired devices, opened here and
|
|
@@ -13,34 +22,134 @@ const OFFER_TTL_MS = 10 * 60_000;
|
|
|
13
22
|
const MAX_OFFERS = 5;
|
|
14
23
|
// A message queued at Harness while this computer slept may arrive up to 7 days late; only decisions and posts may.
|
|
15
24
|
const QUEUED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
-
const QUEUEABLE = new Set([
|
|
17
|
-
"board.
|
|
25
|
+
const QUEUEABLE = new Set([
|
|
26
|
+
"board.post",
|
|
27
|
+
"board.run",
|
|
28
|
+
"board.release",
|
|
29
|
+
"board.answer",
|
|
30
|
+
"board.remove",
|
|
31
|
+
"board.thread.post",
|
|
32
|
+
"board.thread.send",
|
|
33
|
+
"board.thread.discard",
|
|
34
|
+
"board.thread.end",
|
|
35
|
+
"board.goal.stop",
|
|
36
|
+
"board.swarm.start",
|
|
37
|
+
"board.swarm.post",
|
|
38
|
+
"board.swarm.stop",
|
|
39
|
+
"board.swarm.stop_all",
|
|
40
|
+
"board.swarm.resume",
|
|
41
|
+
"board.swarm.start_run",
|
|
42
|
+
]);
|
|
18
43
|
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
19
44
|
const KEY = /^[A-Za-z0-9_-]{80,100}$/;
|
|
20
45
|
const KINDS = new Set(["phone", "browser"]);
|
|
21
46
|
const PHONE_COMMANDS = new Set([
|
|
22
|
-
"board.list",
|
|
23
|
-
"board.
|
|
24
|
-
"board.
|
|
25
|
-
"board.
|
|
26
|
-
"board.
|
|
47
|
+
"board.list",
|
|
48
|
+
"board.get",
|
|
49
|
+
"board.children",
|
|
50
|
+
"board.agents",
|
|
51
|
+
"board.post",
|
|
52
|
+
"board.run",
|
|
53
|
+
"board.release",
|
|
54
|
+
"board.answer",
|
|
55
|
+
"board.remove",
|
|
56
|
+
"board.thread.read",
|
|
57
|
+
"board.thread.post",
|
|
58
|
+
"board.thread.send",
|
|
59
|
+
"board.thread.discard",
|
|
60
|
+
"board.thread.auto",
|
|
61
|
+
"board.thread.end",
|
|
62
|
+
"board.goal.list",
|
|
63
|
+
"board.goal.get",
|
|
64
|
+
"board.goal.stop",
|
|
65
|
+
"board.room.read",
|
|
66
|
+
// A thread's files: shown, opened and downloaded, and shared or kept on this computer, by the person.
|
|
67
|
+
"board.file.get",
|
|
68
|
+
"board.file.share",
|
|
69
|
+
"board.swarm.list",
|
|
70
|
+
"board.swarm.read",
|
|
71
|
+
"board.swarm.start",
|
|
72
|
+
"board.swarm.post",
|
|
73
|
+
"board.swarm.stop",
|
|
74
|
+
"board.swarm.stop_all",
|
|
75
|
+
"board.swarm.resume",
|
|
76
|
+
"board.swarm.folder",
|
|
77
|
+
"board.swarm.coordinator",
|
|
78
|
+
"board.swarm.diff",
|
|
79
|
+
"board.swarm.merge",
|
|
80
|
+
"board.swarm.discard",
|
|
81
|
+
"board.swarm.start_run",
|
|
82
|
+
"board.swarm.agents",
|
|
83
|
+
"board.swarm.note",
|
|
27
84
|
// Where a thread works: the folders offered, any folder browsed to, and where a message would work (folders.mjs).
|
|
28
|
-
"board.swarm.places",
|
|
85
|
+
"board.swarm.places",
|
|
86
|
+
"board.swarm.browse",
|
|
87
|
+
"board.swarm.place",
|
|
29
88
|
// The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
|
|
30
|
-
"board.live",
|
|
89
|
+
"board.live",
|
|
90
|
+
"board.live.read",
|
|
91
|
+
"board.live.say",
|
|
31
92
|
]);
|
|
32
93
|
// What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
|
|
33
94
|
const HARNESS_COMMANDS = new Set([
|
|
34
|
-
"board.list",
|
|
35
|
-
"board.
|
|
36
|
-
"board.
|
|
95
|
+
"board.list",
|
|
96
|
+
"board.get",
|
|
97
|
+
"board.post",
|
|
98
|
+
"board.claim",
|
|
99
|
+
"board.report",
|
|
100
|
+
"board.answer",
|
|
101
|
+
"board.thread.read",
|
|
102
|
+
"board.thread.post",
|
|
103
|
+
"board.standing.check",
|
|
104
|
+
"board.goal.create",
|
|
105
|
+
"board.goal.get",
|
|
106
|
+
"board.goal.list",
|
|
107
|
+
"board.goal.finish",
|
|
108
|
+
"board.room.read",
|
|
109
|
+
"board.room.post",
|
|
110
|
+
"board.room.search",
|
|
111
|
+
"board.children",
|
|
112
|
+
"board.agents",
|
|
113
|
+
"board.swarm.list",
|
|
114
|
+
"board.swarm.read",
|
|
115
|
+
"board.swarm.diff",
|
|
37
116
|
]);
|
|
38
117
|
// Swarms talking to swarms (network.mjs), from the Harness window: the person's own, never Harness's.
|
|
39
|
-
const RELAY_PERSON_COMMANDS = [
|
|
40
|
-
|
|
41
|
-
|
|
118
|
+
const RELAY_PERSON_COMMANDS = [
|
|
119
|
+
"board.relay.list",
|
|
120
|
+
"board.relay.network",
|
|
121
|
+
"board.relay.decide",
|
|
122
|
+
"board.relay.request",
|
|
123
|
+
"board.relay.seen",
|
|
124
|
+
"board.relay.defaults",
|
|
125
|
+
"board.relay.peers",
|
|
126
|
+
];
|
|
127
|
+
const BROWSER_PERSON_COMMANDS = new Set([
|
|
128
|
+
...PHONE_COMMANDS,
|
|
129
|
+
"board.goal.resume",
|
|
130
|
+
"board.standing",
|
|
131
|
+
"board.standing.check",
|
|
132
|
+
"board.standing.off",
|
|
133
|
+
...RELAY_PERSON_COMMANDS,
|
|
134
|
+
]);
|
|
135
|
+
const BROWSER_READS = new Set([
|
|
136
|
+
"sessions.search",
|
|
137
|
+
"sessions.read",
|
|
138
|
+
"sessions.raw",
|
|
139
|
+
"tasks.status",
|
|
140
|
+
"relay.request",
|
|
141
|
+
"relay.open",
|
|
142
|
+
]);
|
|
42
143
|
|
|
43
|
-
export async function createPhoneDesk({
|
|
144
|
+
export async function createPhoneDesk({
|
|
145
|
+
store,
|
|
146
|
+
identityFile,
|
|
147
|
+
boardCommand,
|
|
148
|
+
reads = {},
|
|
149
|
+
version,
|
|
150
|
+
onMessage = () => {},
|
|
151
|
+
machine = () => null,
|
|
152
|
+
}) {
|
|
44
153
|
const identity = await loadOrCreateIdentity(identityFile);
|
|
45
154
|
const offers = new Map();
|
|
46
155
|
const keys = new Map();
|
|
@@ -48,10 +157,15 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
48
157
|
|
|
49
158
|
const sweep = () => {
|
|
50
159
|
const now = Date.now();
|
|
51
|
-
for (const [id, offer] of offers)
|
|
160
|
+
for (const [id, offer] of offers)
|
|
161
|
+
if (offer.expiresAt < now) offers.delete(id);
|
|
52
162
|
};
|
|
53
163
|
const keyFor = async (device) => {
|
|
54
|
-
if (!keys.has(device.id))
|
|
164
|
+
if (!keys.has(device.id))
|
|
165
|
+
keys.set(
|
|
166
|
+
device.id,
|
|
167
|
+
await channelKey(identity.privateKey, device.public_key, device.id)
|
|
168
|
+
);
|
|
55
169
|
return keys.get(device.id);
|
|
56
170
|
};
|
|
57
171
|
|
|
@@ -64,52 +178,152 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
64
178
|
const offerId = b64u(randomBytes(16));
|
|
65
179
|
const secret = b64u(randomBytes(32));
|
|
66
180
|
offers.set(offerId, { secret, kind, expiresAt: Date.now() + OFFER_TTL_MS });
|
|
67
|
-
const fragment = new URLSearchParams({
|
|
181
|
+
const fragment = new URLSearchParams({
|
|
182
|
+
v: "1",
|
|
183
|
+
o: offerId,
|
|
184
|
+
s: secret,
|
|
185
|
+
k: identity.publicKey,
|
|
186
|
+
h: hostname,
|
|
187
|
+
});
|
|
68
188
|
const page = kind === "browser" ? "/link" : "/m/pair";
|
|
69
|
-
return {
|
|
189
|
+
return {
|
|
190
|
+
offerId,
|
|
191
|
+
kind,
|
|
192
|
+
expiresInSeconds: OFFER_TTL_MS / 1000,
|
|
193
|
+
url: `${String(origin).replace(/\/$/, "")}${page}#${fragment}`,
|
|
194
|
+
};
|
|
70
195
|
};
|
|
71
196
|
|
|
72
197
|
const pair = async (input) => {
|
|
73
198
|
sweep();
|
|
74
199
|
const { offerId, deviceId, devicePublicKey, proof, requestId } = input;
|
|
75
|
-
if (
|
|
200
|
+
if (
|
|
201
|
+
![offerId, deviceId, requestId].every((value) =>
|
|
202
|
+
ID.test(String(value))
|
|
203
|
+
) ||
|
|
204
|
+
!KEY.test(String(devicePublicKey))
|
|
205
|
+
)
|
|
206
|
+
return { ok: false, error: "invalid pairing request" };
|
|
76
207
|
const pending = offers.get(String(offerId));
|
|
77
208
|
// One attempt per code, right or wrong: a guessed proof burns the code.
|
|
78
209
|
offers.delete(String(offerId));
|
|
79
|
-
if (!pending)
|
|
80
|
-
|
|
81
|
-
|
|
210
|
+
if (!pending)
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error:
|
|
214
|
+
"this pairing code expired or was already used; make a new one on the computer",
|
|
215
|
+
};
|
|
216
|
+
if (
|
|
217
|
+
!constantTimeEqual(
|
|
218
|
+
proof,
|
|
219
|
+
await pairProof(
|
|
220
|
+
pending.secret,
|
|
221
|
+
offerId,
|
|
222
|
+
devicePublicKey,
|
|
223
|
+
identity.publicKey
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return { ok: false, error: "the pairing proof did not match" };
|
|
228
|
+
if (store.getPhone(String(deviceId)))
|
|
229
|
+
return { ok: false, error: "this device is already paired" };
|
|
82
230
|
const fallback = pending.kind === "browser" ? "Browser" : "Phone";
|
|
83
|
-
const label =
|
|
84
|
-
|
|
85
|
-
|
|
231
|
+
const label =
|
|
232
|
+
String(input.label || fallback)
|
|
233
|
+
.replace(/[^\p{L}\p{N} ._'-]/gu, "")
|
|
234
|
+
.slice(0, 40) || fallback;
|
|
235
|
+
const device = store.addPhone({
|
|
236
|
+
id: String(deviceId),
|
|
237
|
+
label,
|
|
238
|
+
publicKey: String(devicePublicKey),
|
|
239
|
+
kind: pending.kind,
|
|
240
|
+
});
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
envelope: await seal(
|
|
244
|
+
await keyFor(device),
|
|
245
|
+
"d2p",
|
|
246
|
+
device.id,
|
|
247
|
+
String(requestId),
|
|
248
|
+
{ paired: true, hostname, label, kind: device.kind, version }
|
|
249
|
+
),
|
|
250
|
+
};
|
|
86
251
|
};
|
|
87
252
|
|
|
88
253
|
const call = async (envelope) => {
|
|
89
254
|
sweep();
|
|
90
|
-
if (
|
|
255
|
+
if (
|
|
256
|
+
!envelope ||
|
|
257
|
+
envelope.v !== 1 ||
|
|
258
|
+
!ID.test(String(envelope.deviceId)) ||
|
|
259
|
+
!ID.test(String(envelope.requestId)) ||
|
|
260
|
+
typeof envelope.iv !== "string" ||
|
|
261
|
+
typeof envelope.ct !== "string"
|
|
262
|
+
) {
|
|
91
263
|
return { ok: false, error: "invalid envelope" };
|
|
92
264
|
}
|
|
93
265
|
const device = store.getPhone(envelope.deviceId);
|
|
94
|
-
if (!device)
|
|
95
|
-
|
|
266
|
+
if (!device)
|
|
267
|
+
return { ok: false, error: "unknown device", code: "unpaired" };
|
|
268
|
+
if (device.revoked_at)
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
error: "this device was disconnected from the computer",
|
|
272
|
+
code: "revoked",
|
|
273
|
+
};
|
|
96
274
|
const key = await keyFor(device);
|
|
97
275
|
let request;
|
|
98
|
-
try {
|
|
276
|
+
try {
|
|
277
|
+
request = await open(key, "p2d", envelope);
|
|
278
|
+
} catch {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
error: "the message could not be opened",
|
|
282
|
+
code: "unreadable",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
99
285
|
const queued = request?.queued === true;
|
|
100
|
-
if (queued && !QUEUEABLE.has(String(request?.call?.command || "")))
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
286
|
+
if (queued && !QUEUEABLE.has(String(request?.call?.command || "")))
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: "only posts and decisions wait for the computer",
|
|
290
|
+
code: "refused",
|
|
291
|
+
};
|
|
292
|
+
const age =
|
|
293
|
+
typeof request?.ts === "number" ? Date.now() - request.ts : Infinity;
|
|
294
|
+
if (
|
|
295
|
+
queued
|
|
296
|
+
? age > QUEUED_MAX_AGE_MS || age < -MAX_SKEW_MS
|
|
297
|
+
: Math.abs(age) > MAX_SKEW_MS
|
|
298
|
+
)
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
error: "the message is too old or the device's clock is off",
|
|
302
|
+
code: "stale",
|
|
303
|
+
};
|
|
304
|
+
if (!store.firstPhoneRequest(device.id, String(envelope.requestId)))
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
error: "this message was already received",
|
|
308
|
+
code: "replayed",
|
|
309
|
+
};
|
|
104
310
|
store.touchPhone(device.id);
|
|
105
311
|
// Any message from a paired device keeps a Mac in Standby awake for the next hour (Lid-Closed Mode).
|
|
106
312
|
onMessage();
|
|
107
313
|
if (request.call?.command === "phone.forget") {
|
|
108
314
|
const forgotten = revoke(device.id);
|
|
109
|
-
return {
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
envelope: await seal(key, "d2p", device.id, envelope.requestId, {
|
|
318
|
+
ok: forgotten.ok,
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
110
321
|
}
|
|
111
322
|
const result = await answer(request.call, device);
|
|
112
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
envelope: await seal(key, "d2p", device.id, envelope.requestId, result),
|
|
326
|
+
};
|
|
113
327
|
};
|
|
114
328
|
|
|
115
329
|
const settled = async (run) => {
|
|
@@ -117,42 +331,175 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
117
331
|
const result = run();
|
|
118
332
|
return result?.pending ? { ok: true, ...(await result.pending) } : result;
|
|
119
333
|
} catch (error) {
|
|
120
|
-
return {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
error: `the board could not answer: ${error.message}`,
|
|
337
|
+
};
|
|
121
338
|
}
|
|
122
339
|
};
|
|
123
340
|
|
|
124
341
|
const answer = async (callInput, device) => {
|
|
125
342
|
const command = String(callInput?.command || "");
|
|
126
|
-
if (command === "phone.hello")
|
|
343
|
+
if (command === "phone.hello")
|
|
344
|
+
return { ok: true, hostname, version, kind: device.kind };
|
|
127
345
|
if (device.kind === "browser") {
|
|
128
346
|
if (BROWSER_READS.has(command)) {
|
|
129
347
|
const read = reads[command];
|
|
130
|
-
if (!read)
|
|
131
|
-
|
|
348
|
+
if (!read)
|
|
349
|
+
return { ok: false, error: `this computer cannot answer ${command}` };
|
|
350
|
+
try {
|
|
351
|
+
return { ok: true, result: await read(callInput) };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return { ok: false, error: error.message };
|
|
354
|
+
}
|
|
132
355
|
}
|
|
133
356
|
// The browser says whose call it is: the person's own action, or the chat owner's tool call.
|
|
134
357
|
const as = callInput.as === "harness" ? "harness" : "user";
|
|
135
|
-
if (
|
|
358
|
+
if (
|
|
359
|
+
!(as === "harness" ? HARNESS_COMMANDS : BROWSER_PERSON_COMMANDS).has(
|
|
360
|
+
command
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: `a browser cannot call ${command || "that"} as ${as}`,
|
|
366
|
+
};
|
|
136
367
|
// Words another agent wrote that drove Harness's call ride as a taint the board keeps.
|
|
137
|
-
const writtenBy = Array.isArray(callInput.writtenBy)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
368
|
+
const writtenBy = Array.isArray(callInput.writtenBy)
|
|
369
|
+
? callInput.writtenBy.map(String).slice(0, 16)
|
|
370
|
+
: [];
|
|
371
|
+
if (
|
|
372
|
+
as === "harness" &&
|
|
373
|
+
command === "board.goal.create" &&
|
|
374
|
+
writtenBy.length
|
|
375
|
+
)
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
error: "a goal is set only in a turn the owner started",
|
|
379
|
+
};
|
|
380
|
+
const {
|
|
381
|
+
as: _as,
|
|
382
|
+
writtenBy: _written,
|
|
383
|
+
provenance: _provenance,
|
|
384
|
+
...rest
|
|
385
|
+
} = callInput;
|
|
386
|
+
return settled(() =>
|
|
387
|
+
boardCommand({
|
|
388
|
+
...rest,
|
|
389
|
+
command,
|
|
390
|
+
agent: as,
|
|
391
|
+
...(writtenBy.length
|
|
392
|
+
? { provenance: { trust: "agent", chain: writtenBy } }
|
|
393
|
+
: {}),
|
|
394
|
+
})
|
|
395
|
+
);
|
|
141
396
|
}
|
|
142
397
|
// Lid-Closed Mode's wake: no arguments, never queued, and it can only keep the Mac awake and say how it is.
|
|
143
398
|
if (command === "machine.wake") return { ok: true, machine: machine() };
|
|
144
|
-
if (!PHONE_COMMANDS.has(command))
|
|
399
|
+
if (!PHONE_COMMANDS.has(command))
|
|
400
|
+
return { ok: false, error: `a phone cannot call ${command || "that"}` };
|
|
145
401
|
// The phone is the person; whatever it names itself, it acts as "user".
|
|
146
|
-
return settled(() =>
|
|
402
|
+
return settled(() =>
|
|
403
|
+
boardCommand({
|
|
404
|
+
...callInput,
|
|
405
|
+
command,
|
|
406
|
+
agent: "user",
|
|
407
|
+
...(command === "board.list" && callInput.all === undefined
|
|
408
|
+
? { all: true }
|
|
409
|
+
: {}),
|
|
410
|
+
})
|
|
411
|
+
);
|
|
147
412
|
};
|
|
148
413
|
|
|
149
414
|
// Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
|
|
150
|
-
const handle = async (input) =>
|
|
415
|
+
const handle = async (input) =>
|
|
416
|
+
input?.kind === "pair"
|
|
417
|
+
? pair(input)
|
|
418
|
+
: input?.kind === "call"
|
|
419
|
+
? call(input.envelope)
|
|
420
|
+
: { ok: false, error: "unknown sealed request" };
|
|
421
|
+
|
|
422
|
+
// The other way round: this computer asking the person's own Harness window for what only the window holds (its
|
|
423
|
+
// screen history). The question is sealed to every browser linked here ("d2b") under one fresh request id; Harness
|
|
424
|
+
// hands the envelopes to the window that is open (`carry`, the bridge's leg), only a browser holding one of these
|
|
425
|
+
// links can open its envelope, and its answer comes back sealed ("b2d") for this request id alone. Harness sees which
|
|
426
|
+
// devices, sizes and timing, never the question or the answer.
|
|
427
|
+
const ask = async (call, carry) => {
|
|
428
|
+
const browsers = store
|
|
429
|
+
.phones()
|
|
430
|
+
.filter((device) => device.kind === "browser" && !device.revoked_at)
|
|
431
|
+
.slice(0, 8)
|
|
432
|
+
.map((device) => store.getPhone(device.id));
|
|
433
|
+
if (!browsers.length)
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
code: "unpaired",
|
|
437
|
+
error:
|
|
438
|
+
"no Harness window is linked to this computer: the person links one with npx tryharness link",
|
|
439
|
+
};
|
|
440
|
+
const requestId = b64u(randomBytes(16));
|
|
441
|
+
const ts = Date.now();
|
|
442
|
+
const envelopes = await Promise.all(
|
|
443
|
+
browsers.map(async (device) =>
|
|
444
|
+
seal(await keyFor(device), "d2b", device.id, requestId, { ts, call })
|
|
445
|
+
)
|
|
446
|
+
);
|
|
447
|
+
const reply = await carry(envelopes);
|
|
448
|
+
if (reply?.window === "closed")
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
code: "closed",
|
|
452
|
+
error:
|
|
453
|
+
"the person's Harness window isn't open: ask them to open Harness, then try again",
|
|
454
|
+
};
|
|
455
|
+
if (reply?.window !== "open")
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
error: reply?.error || "Harness could not carry the question",
|
|
459
|
+
};
|
|
460
|
+
if (!reply.ok || !reply.envelope)
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
code: reply.code,
|
|
464
|
+
error:
|
|
465
|
+
reply.code === "window"
|
|
466
|
+
? `the open Harness window could not answer: ${reply.error}`
|
|
467
|
+
: reply.error,
|
|
468
|
+
};
|
|
469
|
+
const device = browsers.find((item) => item.id === reply.envelope.deviceId);
|
|
470
|
+
if (!device || reply.envelope.requestId !== requestId)
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
code: "unreadable",
|
|
474
|
+
error: "the answer was for another question",
|
|
475
|
+
};
|
|
476
|
+
let answer;
|
|
477
|
+
try {
|
|
478
|
+
answer = await open(await keyFor(device), "b2d", reply.envelope);
|
|
479
|
+
} catch {
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
code: "unreadable",
|
|
483
|
+
error:
|
|
484
|
+
"the answer could not be opened: it did not come from a window linked to this computer",
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
store.touchPhone(device.id);
|
|
488
|
+
return answer;
|
|
489
|
+
};
|
|
151
490
|
|
|
152
491
|
const revoke = (id) => {
|
|
153
492
|
keys.delete(String(id));
|
|
154
493
|
return store.revokePhone(String(id));
|
|
155
494
|
};
|
|
156
495
|
|
|
157
|
-
return {
|
|
496
|
+
return {
|
|
497
|
+
offer,
|
|
498
|
+
handle,
|
|
499
|
+
ask,
|
|
500
|
+
revoke,
|
|
501
|
+
list: () => store.phones(),
|
|
502
|
+
publicKey: identity.publicKey,
|
|
503
|
+
identity,
|
|
504
|
+
};
|
|
158
505
|
}
|