tryharness 0.7.23 → 0.7.26
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/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5268 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- 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 +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +384 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- package/package.json +3 -3
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,36 +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.
|
|
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",
|
|
25
66
|
// A thread's files: shown, opened and downloaded, and shared or kept on this computer, by the person.
|
|
26
|
-
"board.file.get",
|
|
27
|
-
"board.
|
|
28
|
-
"board.swarm.
|
|
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",
|
|
29
84
|
// Where a thread works: the folders offered, any folder browsed to, and where a message would work (folders.mjs).
|
|
30
|
-
"board.swarm.places",
|
|
85
|
+
"board.swarm.places",
|
|
86
|
+
"board.swarm.browse",
|
|
87
|
+
"board.swarm.place",
|
|
31
88
|
// The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
|
|
32
|
-
"board.live",
|
|
89
|
+
"board.live",
|
|
90
|
+
"board.live.read",
|
|
91
|
+
"board.live.say",
|
|
33
92
|
]);
|
|
34
93
|
// What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
|
|
35
94
|
const HARNESS_COMMANDS = new Set([
|
|
36
|
-
"board.list",
|
|
37
|
-
"board.
|
|
38
|
-
"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",
|
|
39
116
|
]);
|
|
40
117
|
// Swarms talking to swarms (network.mjs), from the Harness window: the person's own, never Harness's.
|
|
41
|
-
const RELAY_PERSON_COMMANDS = [
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
]);
|
|
44
143
|
|
|
45
|
-
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
|
+
}) {
|
|
46
153
|
const identity = await loadOrCreateIdentity(identityFile);
|
|
47
154
|
const offers = new Map();
|
|
48
155
|
const keys = new Map();
|
|
@@ -50,10 +157,15 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
50
157
|
|
|
51
158
|
const sweep = () => {
|
|
52
159
|
const now = Date.now();
|
|
53
|
-
for (const [id, offer] of offers)
|
|
160
|
+
for (const [id, offer] of offers)
|
|
161
|
+
if (offer.expiresAt < now) offers.delete(id);
|
|
54
162
|
};
|
|
55
163
|
const keyFor = async (device) => {
|
|
56
|
-
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
|
+
);
|
|
57
169
|
return keys.get(device.id);
|
|
58
170
|
};
|
|
59
171
|
|
|
@@ -66,52 +178,152 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
66
178
|
const offerId = b64u(randomBytes(16));
|
|
67
179
|
const secret = b64u(randomBytes(32));
|
|
68
180
|
offers.set(offerId, { secret, kind, expiresAt: Date.now() + OFFER_TTL_MS });
|
|
69
|
-
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
|
+
});
|
|
70
188
|
const page = kind === "browser" ? "/link" : "/m/pair";
|
|
71
|
-
return {
|
|
189
|
+
return {
|
|
190
|
+
offerId,
|
|
191
|
+
kind,
|
|
192
|
+
expiresInSeconds: OFFER_TTL_MS / 1000,
|
|
193
|
+
url: `${String(origin).replace(/\/$/, "")}${page}#${fragment}`,
|
|
194
|
+
};
|
|
72
195
|
};
|
|
73
196
|
|
|
74
197
|
const pair = async (input) => {
|
|
75
198
|
sweep();
|
|
76
199
|
const { offerId, deviceId, devicePublicKey, proof, requestId } = input;
|
|
77
|
-
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" };
|
|
78
207
|
const pending = offers.get(String(offerId));
|
|
79
208
|
// One attempt per code, right or wrong: a guessed proof burns the code.
|
|
80
209
|
offers.delete(String(offerId));
|
|
81
|
-
if (!pending)
|
|
82
|
-
|
|
83
|
-
|
|
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" };
|
|
84
230
|
const fallback = pending.kind === "browser" ? "Browser" : "Phone";
|
|
85
|
-
const label =
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
};
|
|
88
251
|
};
|
|
89
252
|
|
|
90
253
|
const call = async (envelope) => {
|
|
91
254
|
sweep();
|
|
92
|
-
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
|
+
) {
|
|
93
263
|
return { ok: false, error: "invalid envelope" };
|
|
94
264
|
}
|
|
95
265
|
const device = store.getPhone(envelope.deviceId);
|
|
96
|
-
if (!device)
|
|
97
|
-
|
|
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
|
+
};
|
|
98
274
|
const key = await keyFor(device);
|
|
99
275
|
let request;
|
|
100
|
-
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
|
+
}
|
|
101
285
|
const queued = request?.queued === true;
|
|
102
|
-
if (queued && !QUEUEABLE.has(String(request?.call?.command || "")))
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
};
|
|
106
310
|
store.touchPhone(device.id);
|
|
107
311
|
// Any message from a paired device keeps a Mac in Standby awake for the next hour (Lid-Closed Mode).
|
|
108
312
|
onMessage();
|
|
109
313
|
if (request.call?.command === "phone.forget") {
|
|
110
314
|
const forgotten = revoke(device.id);
|
|
111
|
-
return {
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
envelope: await seal(key, "d2p", device.id, envelope.requestId, {
|
|
318
|
+
ok: forgotten.ok,
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
112
321
|
}
|
|
113
322
|
const result = await answer(request.call, device);
|
|
114
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
envelope: await seal(key, "d2p", device.id, envelope.requestId, result),
|
|
326
|
+
};
|
|
115
327
|
};
|
|
116
328
|
|
|
117
329
|
const settled = async (run) => {
|
|
@@ -119,37 +331,95 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
119
331
|
const result = run();
|
|
120
332
|
return result?.pending ? { ok: true, ...(await result.pending) } : result;
|
|
121
333
|
} catch (error) {
|
|
122
|
-
return {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
error: `the board could not answer: ${error.message}`,
|
|
337
|
+
};
|
|
123
338
|
}
|
|
124
339
|
};
|
|
125
340
|
|
|
126
341
|
const answer = async (callInput, device) => {
|
|
127
342
|
const command = String(callInput?.command || "");
|
|
128
|
-
if (command === "phone.hello")
|
|
343
|
+
if (command === "phone.hello")
|
|
344
|
+
return { ok: true, hostname, version, kind: device.kind };
|
|
129
345
|
if (device.kind === "browser") {
|
|
130
346
|
if (BROWSER_READS.has(command)) {
|
|
131
347
|
const read = reads[command];
|
|
132
|
-
if (!read)
|
|
133
|
-
|
|
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
|
+
}
|
|
134
355
|
}
|
|
135
356
|
// The browser says whose call it is: the person's own action, or the chat owner's tool call.
|
|
136
357
|
const as = callInput.as === "harness" ? "harness" : "user";
|
|
137
|
-
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
|
+
};
|
|
138
367
|
// Words another agent wrote that drove Harness's call ride as a taint the board keeps.
|
|
139
|
-
const writtenBy = Array.isArray(callInput.writtenBy)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
// A Harness window's own call, which daemon.mjs checks before it takes a task given to Harness.
|
|
392
|
+
fromWindow: as === "harness",
|
|
393
|
+
...(writtenBy.length
|
|
394
|
+
? { provenance: { trust: "agent", chain: writtenBy } }
|
|
395
|
+
: {}),
|
|
396
|
+
})
|
|
397
|
+
);
|
|
143
398
|
}
|
|
144
399
|
// Lid-Closed Mode's wake: no arguments, never queued, and it can only keep the Mac awake and say how it is.
|
|
145
400
|
if (command === "machine.wake") return { ok: true, machine: machine() };
|
|
146
|
-
if (!PHONE_COMMANDS.has(command))
|
|
401
|
+
if (!PHONE_COMMANDS.has(command))
|
|
402
|
+
return { ok: false, error: `a phone cannot call ${command || "that"}` };
|
|
147
403
|
// The phone is the person; whatever it names itself, it acts as "user".
|
|
148
|
-
return settled(() =>
|
|
404
|
+
return settled(() =>
|
|
405
|
+
boardCommand({
|
|
406
|
+
...callInput,
|
|
407
|
+
command,
|
|
408
|
+
agent: "user",
|
|
409
|
+
...(command === "board.list" && callInput.all === undefined
|
|
410
|
+
? { all: true }
|
|
411
|
+
: {}),
|
|
412
|
+
})
|
|
413
|
+
);
|
|
149
414
|
};
|
|
150
415
|
|
|
151
416
|
// Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
|
|
152
|
-
const handle = async (input) =>
|
|
417
|
+
const handle = async (input) =>
|
|
418
|
+
input?.kind === "pair"
|
|
419
|
+
? pair(input)
|
|
420
|
+
: input?.kind === "call"
|
|
421
|
+
? call(input.envelope)
|
|
422
|
+
: { ok: false, error: "unknown sealed request" };
|
|
153
423
|
|
|
154
424
|
// The other way round: this computer asking the person's own Harness window for what only the window holds (its
|
|
155
425
|
// screen history). The question is sealed to every browser linked here ("d2b") under one fresh request id; Harness
|
|
@@ -157,19 +427,65 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
157
427
|
// links can open its envelope, and its answer comes back sealed ("b2d") for this request id alone. Harness sees which
|
|
158
428
|
// devices, sizes and timing, never the question or the answer.
|
|
159
429
|
const ask = async (call, carry) => {
|
|
160
|
-
const browsers = store
|
|
161
|
-
|
|
430
|
+
const browsers = store
|
|
431
|
+
.phones()
|
|
432
|
+
.filter((device) => device.kind === "browser" && !device.revoked_at)
|
|
433
|
+
.slice(0, 8)
|
|
434
|
+
.map((device) => store.getPhone(device.id));
|
|
435
|
+
if (!browsers.length)
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
code: "unpaired",
|
|
439
|
+
error:
|
|
440
|
+
"no Harness window is linked to this computer: the person links one with npx tryharness link",
|
|
441
|
+
};
|
|
162
442
|
const requestId = b64u(randomBytes(16));
|
|
163
443
|
const ts = Date.now();
|
|
164
|
-
const envelopes = await Promise.all(
|
|
444
|
+
const envelopes = await Promise.all(
|
|
445
|
+
browsers.map(async (device) =>
|
|
446
|
+
seal(await keyFor(device), "d2b", device.id, requestId, { ts, call })
|
|
447
|
+
)
|
|
448
|
+
);
|
|
165
449
|
const reply = await carry(envelopes);
|
|
166
|
-
if (reply?.window === "closed")
|
|
167
|
-
|
|
168
|
-
|
|
450
|
+
if (reply?.window === "closed")
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
code: "closed",
|
|
454
|
+
error:
|
|
455
|
+
"the person's Harness window isn't open: ask them to open Harness, then try again",
|
|
456
|
+
};
|
|
457
|
+
if (reply?.window !== "open")
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
error: reply?.error || "Harness could not carry the question",
|
|
461
|
+
};
|
|
462
|
+
if (!reply.ok || !reply.envelope)
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
code: reply.code,
|
|
466
|
+
error:
|
|
467
|
+
reply.code === "window"
|
|
468
|
+
? `the open Harness window could not answer: ${reply.error}`
|
|
469
|
+
: reply.error,
|
|
470
|
+
};
|
|
169
471
|
const device = browsers.find((item) => item.id === reply.envelope.deviceId);
|
|
170
|
-
if (!device || reply.envelope.requestId !== requestId)
|
|
472
|
+
if (!device || reply.envelope.requestId !== requestId)
|
|
473
|
+
return {
|
|
474
|
+
ok: false,
|
|
475
|
+
code: "unreadable",
|
|
476
|
+
error: "the answer was for another question",
|
|
477
|
+
};
|
|
171
478
|
let answer;
|
|
172
|
-
try {
|
|
479
|
+
try {
|
|
480
|
+
answer = await open(await keyFor(device), "b2d", reply.envelope);
|
|
481
|
+
} catch {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
code: "unreadable",
|
|
485
|
+
error:
|
|
486
|
+
"the answer could not be opened: it did not come from a window linked to this computer",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
173
489
|
store.touchPhone(device.id);
|
|
174
490
|
return answer;
|
|
175
491
|
};
|
|
@@ -179,5 +495,13 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
179
495
|
return store.revokePhone(String(id));
|
|
180
496
|
};
|
|
181
497
|
|
|
182
|
-
return {
|
|
498
|
+
return {
|
|
499
|
+
offer,
|
|
500
|
+
handle,
|
|
501
|
+
ask,
|
|
502
|
+
revoke,
|
|
503
|
+
list: () => store.phones(),
|
|
504
|
+
publicKey: identity.publicKey,
|
|
505
|
+
identity,
|
|
506
|
+
};
|
|
183
507
|
}
|