agent-yes 1.119.1 → 1.121.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SUPPORTED_CLIS-CegJgoEf.js +8 -0
- package/dist/{SUPPORTED_CLIS-DwPmzY8B.js → SUPPORTED_CLIS-O57LGUEG.js} +2 -2
- package/dist/cli.js +3 -3
- package/dist/index.js +2 -2
- package/dist/{serve-Bd-6ZItj.js → serve-D2czcYNC.js} +29 -18
- package/dist/{setup-DiRSdfeu.js → setup-f1FIFcZm.js} +2 -2
- package/dist/share-B6QVr5D1.js +522 -0
- package/dist/{subcommands-BC_0iPGS.js → subcommands-CzpZQHO6.js} +3 -3
- package/dist/{subcommands-BFHJ2AUQ.js → subcommands-DobVXouH.js} +1 -1
- package/dist/{ts-VrgyWwNH.js → ts-D91dm1E0.js} +2 -2
- package/dist/{versionChecker-BjZOppZJ.js → versionChecker-CAtpgnoQ.js} +2 -2
- package/lab/ui/blog/e2ee-share-links/index.html +299 -0
- package/lab/ui/e2e.d.ts +47 -0
- package/lab/ui/e2e.js +245 -0
- package/lab/ui/index.html +180 -26
- package/package.json +6 -2
- package/scripts/check-e2e.ts +40 -0
- package/ts/e2e-crypto.spec.ts +235 -0
- package/ts/serve.ts +57 -21
- package/ts/share.ts +205 -32
- package/dist/SUPPORTED_CLIS-CwM5JV4y.js +0 -8
- package/dist/share-B7J79Wq9.js +0 -254
package/ts/serve.ts
CHANGED
|
@@ -133,18 +133,32 @@ function ayServeArgv(args: string[]): string[] {
|
|
|
133
133
|
return [...launcher, "serve", ...args];
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// Register the daemon
|
|
137
|
-
//
|
|
138
|
-
// `oxmgr service install
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
136
|
+
// Register the daemon to come up automatically. The *scope* is per-platform by
|
|
137
|
+
// design: Linux → at system boot (before login); Windows → at user login.
|
|
138
|
+
// - Linux (oxmgr): `oxmgr service install` wires a systemd **--user** unit,
|
|
139
|
+
// which on its own only starts after the user logs in. To make it start at
|
|
140
|
+
// boot without requiring root (no system-scope unit, no sudo), we also
|
|
141
|
+
// `loginctl enable-linger`, which keeps the user's systemd instance — and
|
|
142
|
+
// thus our service — running from boot. Best-effort; linger failing just
|
|
143
|
+
// downgrades us to login-scope.
|
|
144
|
+
// - Windows (pm2): `pm2 save` persists the process list so the once-installed
|
|
145
|
+
// `pm2 startup` logon hook resurrects it at user login.
|
|
146
|
+
// Idempotent and best-effort: returns false on failure without aborting the
|
|
147
|
+
// install — the process is still crash-managed, just not boot/login-persistent.
|
|
142
148
|
async function ensureBootAutostart(mgr: DaemonManager): Promise<boolean> {
|
|
143
149
|
try {
|
|
150
|
+
if (mgr.id !== "oxmgr") {
|
|
151
|
+
// pm2 (Windows): logon-scoped resurrect via the saved process list.
|
|
152
|
+
return (await spawnExit([mgr.bin, "save"])) === 0;
|
|
153
|
+
}
|
|
144
154
|
// oxmgr's --system defaults to "auto" (launchd/systemd/Task Scheduler); it's
|
|
145
155
|
// a `service`-level flag, so it goes before the subcommand, not after.
|
|
146
|
-
const
|
|
147
|
-
|
|
156
|
+
const installed = (await spawnExit([mgr.bin, "service", "install"])) === 0;
|
|
157
|
+
if (installed && process.platform === "linux") {
|
|
158
|
+
// Upgrade login-scope → boot-scope: linger starts the user manager at boot.
|
|
159
|
+
await spawnExit(["loginctl", "enable-linger", userInfo().username]);
|
|
160
|
+
}
|
|
161
|
+
return installed;
|
|
148
162
|
} catch {
|
|
149
163
|
return false;
|
|
150
164
|
}
|
|
@@ -281,14 +295,14 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
281
295
|
if (mgr.id === "oxmgr")
|
|
282
296
|
process.stdout.write(
|
|
283
297
|
onBoot
|
|
284
|
-
? `start-on-boot: enabled (
|
|
285
|
-
: `start-on-boot: not registered — run \`oxmgr service install\` to enable\n`,
|
|
298
|
+
? `start-on-boot: enabled (systemd --user + linger, starts at boot)\n`
|
|
299
|
+
: `start-on-boot: not registered — needs a user systemd session; run \`oxmgr service install\` to enable\n`,
|
|
286
300
|
);
|
|
287
301
|
else
|
|
288
302
|
process.stdout.write(
|
|
289
303
|
onBoot
|
|
290
|
-
? `start-on-
|
|
291
|
-
: `start-on-
|
|
304
|
+
? `start-on-login: enabled (pm2 list saved; run \`pm2 startup\` once if logon resurrect is not yet installed)\n`
|
|
305
|
+
: `start-on-login: \`pm2 save\` failed — run it manually to persist across logins\n`,
|
|
292
306
|
);
|
|
293
307
|
process.stdout.write(`token: ${token}\n\n`);
|
|
294
308
|
if (httpish) {
|
|
@@ -299,8 +313,9 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
299
313
|
process.stdout.write(` ay serve uninstall # remove daemon\n`);
|
|
300
314
|
if (webrtcish) {
|
|
301
315
|
process.stdout.write(
|
|
302
|
-
`\nthe WebRTC share link
|
|
303
|
-
`
|
|
316
|
+
`\nthe WebRTC share link carries a secret, so the daemon does NOT log it —\n` +
|
|
317
|
+
`read it from ~/.agent-yes/.share-link (mode 0600). The room persists in\n` +
|
|
318
|
+
`~/.agent-yes/.share-room, so the link survives restarts.\n`,
|
|
304
319
|
);
|
|
305
320
|
}
|
|
306
321
|
}
|
|
@@ -988,6 +1003,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
988
1003
|
return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
|
|
989
1004
|
if (req.method === "GET" && p === "/console-logic.js")
|
|
990
1005
|
return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
|
|
1006
|
+
if (req.method === "GET" && p === "/e2e.js")
|
|
1007
|
+
return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
|
|
991
1008
|
if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
|
|
992
1009
|
return apiFetch(req);
|
|
993
1010
|
};
|
|
@@ -1049,18 +1066,37 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1049
1066
|
const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
|
|
1050
1067
|
// No explicit webrtc:// URL → reuse the persisted room (minted once and
|
|
1051
1068
|
// saved like the serve token), so the link is stable across restarts.
|
|
1052
|
-
const { link, close } = await startShare({
|
|
1069
|
+
const { room, link, close } = await startShare({
|
|
1053
1070
|
url: explicitUrl ?? (await loadOrCreateShareRoom()),
|
|
1054
1071
|
localFetch: apiFetch,
|
|
1055
1072
|
apiToken: token,
|
|
1056
1073
|
});
|
|
1057
1074
|
closeShare = close;
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1075
|
+
const persistNote = explicitUrl
|
|
1076
|
+
? "\n"
|
|
1077
|
+
: ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`;
|
|
1078
|
+
if (process.stdout.isTTY) {
|
|
1079
|
+
process.stdout.write(
|
|
1080
|
+
`${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` +
|
|
1081
|
+
persistNote,
|
|
1082
|
+
);
|
|
1083
|
+
} else {
|
|
1084
|
+
// Non-TTY (daemon/journal/CI): the link embeds the room secret S, so never
|
|
1085
|
+
// write it to a log stream. Stash it in a 0600 file and point there instead.
|
|
1086
|
+
const linkFile = path.join(
|
|
1087
|
+
process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"),
|
|
1088
|
+
".share-link",
|
|
1089
|
+
);
|
|
1090
|
+
try {
|
|
1091
|
+
await writeFile(linkFile, link + "\n", { mode: 0o600 });
|
|
1092
|
+
} catch {
|
|
1093
|
+
/* best effort */
|
|
1094
|
+
}
|
|
1095
|
+
process.stdout.write(
|
|
1096
|
+
`${wantHttp ? "\n" : ""}shared over WebRTC · room ${room} — the link carries a secret, so it is NOT logged.\n` +
|
|
1097
|
+
` read it from ${linkFile} (mode 0600); delete ~/.agent-yes/.share-room to rotate\n\n`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1064
1100
|
} catch (e) {
|
|
1065
1101
|
process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
|
|
1066
1102
|
if (!wantHttp) return 1; // nothing else is running
|
package/ts/share.ts
CHANGED
|
@@ -8,10 +8,24 @@ import { randomBytes } from "crypto";
|
|
|
8
8
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import path from "path";
|
|
11
|
+
import {
|
|
12
|
+
CONFIRM_TIMEOUT_MS,
|
|
13
|
+
FLAG_CONFIRM,
|
|
14
|
+
MARKER,
|
|
15
|
+
MAX_CHUNK,
|
|
16
|
+
computeTranscriptHash,
|
|
17
|
+
deriveAuthToken,
|
|
18
|
+
deriveDirKeys,
|
|
19
|
+
open as e2eOpen,
|
|
20
|
+
seal as e2eSeal,
|
|
21
|
+
packEnvelope,
|
|
22
|
+
parseSecret,
|
|
23
|
+
randomHex,
|
|
24
|
+
unpackEnvelope,
|
|
25
|
+
} from "../lab/ui/e2e.js";
|
|
11
26
|
|
|
12
27
|
const SUB = "ay-signal-1";
|
|
13
28
|
const ICE = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
14
|
-
const MAX_CHUNK = 15_000; // keep DataChannel messages under the SCTP limit
|
|
15
29
|
const DEFAULT_SIGHOST = "s.agent-yes.com";
|
|
16
30
|
|
|
17
31
|
export interface ShareOpts {
|
|
@@ -38,13 +52,20 @@ function shareRoomPath(): string {
|
|
|
38
52
|
export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<string> {
|
|
39
53
|
try {
|
|
40
54
|
const url = (await readFile(shareRoomPath(), "utf-8")).trim();
|
|
41
|
-
if (url.startsWith("webrtc://"))
|
|
55
|
+
if (url.startsWith("webrtc://")) {
|
|
56
|
+
// A v2 (encrypted) room carries the e1. marker on its secret — reuse it.
|
|
57
|
+
// A legacy markerless room is rotated to a fresh encrypted room below: the
|
|
58
|
+
// signaling DO has pinned the old room to its plaintext token, so we must
|
|
59
|
+
// mint a NEW room name. This is a one-time, deliberate security upgrade —
|
|
60
|
+
// old share links stop working; re-open the new printed link.
|
|
61
|
+
if (parseShareUrl(url).token.startsWith(MARKER)) return url;
|
|
62
|
+
}
|
|
42
63
|
} catch {
|
|
43
64
|
/* not yet minted */
|
|
44
65
|
}
|
|
45
66
|
const room = "r" + randomBytes(3).toString("hex");
|
|
46
|
-
const
|
|
47
|
-
const url = `webrtc://${room}:${
|
|
67
|
+
const s = randomBytes(32).toString("hex");
|
|
68
|
+
const url = `webrtc://${room}:${MARKER}${s}@${sighost}`;
|
|
48
69
|
await mkdir(path.dirname(shareRoomPath()), { recursive: true });
|
|
49
70
|
await writeFile(shareRoomPath(), url, { mode: 0o600 });
|
|
50
71
|
return url;
|
|
@@ -146,17 +167,46 @@ export async function startShare(
|
|
|
146
167
|
? parseShareUrl(opts.url)
|
|
147
168
|
: {
|
|
148
169
|
room: "r" + randomBytes(3).toString("hex"),
|
|
149
|
-
token: randomBytes(32).toString("hex")
|
|
170
|
+
token: `${MARKER}${randomBytes(32).toString("hex")}`,
|
|
150
171
|
host: sighost,
|
|
151
172
|
};
|
|
152
173
|
|
|
174
|
+
// E2E: the URL secret S splits into authToken (the only value the server sees,
|
|
175
|
+
// for room matching) and per-connection AES keys the server never sees. We
|
|
176
|
+
// refuse to host a legacy plaintext room — old rooms are auto-rotated to v2 by
|
|
177
|
+
// loadOrCreateShareRoom (delete ~/.agent-yes/.share-room to force a rotation).
|
|
178
|
+
const { s: S, v2 } = parseSecret(token);
|
|
179
|
+
if (!v2) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"refusing to host an unencrypted room — delete ~/.agent-yes/.share-room to rotate to an encrypted link",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const authToken = await deriveAuthToken(S, room, host);
|
|
185
|
+
|
|
153
186
|
const RTCPeerConnection = await importRTC();
|
|
154
187
|
const wsScheme = host.startsWith("localhost") || host.startsWith("127.") ? "ws" : "wss";
|
|
155
188
|
const ui = host === "s.agent-yes.com" ? "https://agent-yes.com" : "http://localhost:7778";
|
|
156
189
|
const suffix = host === "s.agent-yes.com" ? "" : "@" + host;
|
|
157
|
-
const link = `${ui}/#${room}:${
|
|
190
|
+
const link = `${ui}/#${room}:${MARKER}${S}${suffix}`;
|
|
158
191
|
|
|
159
|
-
type Peer = {
|
|
192
|
+
type Peer = {
|
|
193
|
+
pc: any;
|
|
194
|
+
aborts: Map<string, AbortController>;
|
|
195
|
+
send: { sendCtr: bigint };
|
|
196
|
+
recv: { lastSeen: bigint };
|
|
197
|
+
th?: Uint8Array;
|
|
198
|
+
keyH2C?: CryptoKey; // host encrypts with H2C, decrypts with C2H
|
|
199
|
+
keyC2H?: CryptoKey;
|
|
200
|
+
keysReady: Promise<void>;
|
|
201
|
+
resolveKeys: () => void;
|
|
202
|
+
myNonce: string;
|
|
203
|
+
confirmedIn: boolean; // peer echoed our nonce
|
|
204
|
+
confirmedOut: boolean; // we echoed peer's nonce
|
|
205
|
+
confirmed: boolean;
|
|
206
|
+
confirmTimer?: ReturnType<typeof setTimeout>;
|
|
207
|
+
recvChain: Promise<void>; // serialize decrypts so the replay counter stays ordered
|
|
208
|
+
sendChain: Promise<void>; // serialize seals so wire order == counter order
|
|
209
|
+
};
|
|
160
210
|
const peers = new Map<string, Peer>();
|
|
161
211
|
let closed = false; // set by close(); stops signaling reconnect + new peers
|
|
162
212
|
let currentWs: WebSocket | undefined; // the live rendezvous socket, for close()
|
|
@@ -167,7 +217,7 @@ export async function startShare(
|
|
|
167
217
|
currentWs = ws;
|
|
168
218
|
let ready = false;
|
|
169
219
|
ws.onopen = () => {
|
|
170
|
-
ws.send(JSON.stringify({ type: "hello", role: "host", token }));
|
|
220
|
+
ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
|
|
171
221
|
ready = true;
|
|
172
222
|
onReady();
|
|
173
223
|
};
|
|
@@ -175,17 +225,44 @@ export async function startShare(
|
|
|
175
225
|
if (closed) return;
|
|
176
226
|
const m = JSON.parse(ev.data as string);
|
|
177
227
|
if (m.type === "peer-join") startPeer(ws, m.peer);
|
|
178
|
-
else if (m.type === "answer")
|
|
179
|
-
|
|
180
|
-
|
|
228
|
+
else if (m.type === "answer") {
|
|
229
|
+
const peer = peers.get(m.from);
|
|
230
|
+
if (!peer) return;
|
|
231
|
+
try {
|
|
232
|
+
await peer.pc.setRemoteDescription({ type: "answer", sdp: m.sdp });
|
|
233
|
+
// Derive per-connection keys the moment both descriptions are stable —
|
|
234
|
+
// before the DataChannel can open and deliver a frame. Host's offer is
|
|
235
|
+
// local, the browser's answer is remote.
|
|
236
|
+
peer.th = await computeTranscriptHash(
|
|
237
|
+
peer.pc.localDescription.sdp,
|
|
238
|
+
peer.pc.remoteDescription.sdp,
|
|
239
|
+
);
|
|
240
|
+
const { keyH2C, keyC2H } = await deriveDirKeys(S, peer.th);
|
|
241
|
+
peer.keyH2C = keyH2C;
|
|
242
|
+
peer.keyC2H = keyC2H;
|
|
243
|
+
peer.resolveKeys();
|
|
244
|
+
} catch {
|
|
245
|
+
closePeer(m.from);
|
|
246
|
+
}
|
|
247
|
+
} else if (m.type === "candidate")
|
|
181
248
|
await peers
|
|
182
249
|
.get(m.from)
|
|
183
250
|
?.pc.addIceCandidate(m.candidate)
|
|
184
251
|
.catch(() => {});
|
|
185
252
|
else if (m.type === "peer-leave") closePeer(m.peer);
|
|
186
253
|
};
|
|
187
|
-
ws.onclose = () => {
|
|
254
|
+
ws.onclose = (ev: any) => {
|
|
188
255
|
if (closed) return; // shutting down — don't resurrect the rendezvous
|
|
256
|
+
// The signaling server pins a room to its first host's authToken. A 1008
|
|
257
|
+
// means a different generation already owns this room — don't hot-loop;
|
|
258
|
+
// tell the operator to rotate. (Secret-free message.)
|
|
259
|
+
if (ev?.code === 1008) {
|
|
260
|
+
closed = true;
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
"[share] room rejected by signaling server — delete ~/.agent-yes/.share-room to rotate the room\n",
|
|
263
|
+
);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
189
266
|
// Keep established WebRTC peers; just re-establish the rendezvous so new
|
|
190
267
|
// browsers can still join. Backoff a little to avoid hot-looping.
|
|
191
268
|
setTimeout(() => connectSignaling(() => {}), ready ? 1500 : 4000);
|
|
@@ -196,8 +273,23 @@ export async function startShare(
|
|
|
196
273
|
|
|
197
274
|
function startPeer(ws: WebSocket, peerId: string) {
|
|
198
275
|
const pc = new RTCPeerConnection({ iceServers: ICE });
|
|
199
|
-
|
|
200
|
-
|
|
276
|
+
let resolveKeys!: () => void;
|
|
277
|
+
const keysReady = new Promise<void>((r) => (resolveKeys = r));
|
|
278
|
+
const peer: Peer = {
|
|
279
|
+
pc,
|
|
280
|
+
aborts: new Map<string, AbortController>(),
|
|
281
|
+
send: { sendCtr: 0n },
|
|
282
|
+
recv: { lastSeen: -1n },
|
|
283
|
+
keysReady,
|
|
284
|
+
resolveKeys,
|
|
285
|
+
myNonce: randomHex(16),
|
|
286
|
+
confirmedIn: false,
|
|
287
|
+
confirmedOut: false,
|
|
288
|
+
confirmed: false,
|
|
289
|
+
recvChain: Promise.resolve(),
|
|
290
|
+
sendChain: Promise.resolve(),
|
|
291
|
+
};
|
|
292
|
+
peers.set(peerId, peer);
|
|
201
293
|
pc.onicecandidate = (e: any) => {
|
|
202
294
|
if (e.candidate)
|
|
203
295
|
ws.send(JSON.stringify({ type: "candidate", to: peerId, candidate: e.candidate }));
|
|
@@ -206,7 +298,26 @@ export async function startShare(
|
|
|
206
298
|
if (["failed", "closed", "disconnected"].includes(pc.connectionState)) closePeer(peerId);
|
|
207
299
|
};
|
|
208
300
|
const dc = pc.createDataChannel("api");
|
|
209
|
-
dc.
|
|
301
|
+
dc.binaryType = "arraybuffer";
|
|
302
|
+
dc.onopen = async () => {
|
|
303
|
+
try {
|
|
304
|
+
await peer.keysReady; // keys derived in the answer handler
|
|
305
|
+
// Open the mandatory bidirectional key-confirmation handshake. Nothing
|
|
306
|
+
// the peer sends is acted on until BOTH directions confirm (see onFrame).
|
|
307
|
+
enqueueSeal(peerId, dc, peer, FLAG_CONFIRM, { t: "confirm", nonce: peer.myNonce });
|
|
308
|
+
peer.confirmTimer = setTimeout(() => {
|
|
309
|
+
if (!peer.confirmed) closePeer(peerId);
|
|
310
|
+
}, CONFIRM_TIMEOUT_MS);
|
|
311
|
+
} catch {
|
|
312
|
+
closePeer(peerId);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
// Serialize decrypts: WebCrypto open() is async, and a reliable+ordered
|
|
316
|
+
// channel must be processed in order or the monotonic replay check would
|
|
317
|
+
// spuriously reject a reordered await.
|
|
318
|
+
dc.onmessage = (e: any) => {
|
|
319
|
+
peer.recvChain = peer.recvChain.then(() => onFrame(peerId, dc, peer, e.data)).catch(() => {});
|
|
320
|
+
};
|
|
210
321
|
pc.createOffer()
|
|
211
322
|
.then((o: any) => pc.setLocalDescription(o))
|
|
212
323
|
.then(() =>
|
|
@@ -217,6 +328,7 @@ export async function startShare(
|
|
|
217
328
|
function closePeer(peerId: string) {
|
|
218
329
|
const p = peers.get(peerId);
|
|
219
330
|
if (!p) return;
|
|
331
|
+
if (p.confirmTimer) clearTimeout(p.confirmTimer);
|
|
220
332
|
for (const a of p.aborts.values()) a.abort();
|
|
221
333
|
try {
|
|
222
334
|
p.pc.close();
|
|
@@ -226,29 +338,72 @@ export async function startShare(
|
|
|
226
338
|
peers.delete(peerId);
|
|
227
339
|
}
|
|
228
340
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
341
|
+
// Seal an envelope and send it, serialized per peer so the wire order matches
|
|
342
|
+
// the nonce-counter order (so the receiver's monotonic check never trips).
|
|
343
|
+
function enqueueSeal(peerId: string, dc: any, peer: Peer, flags: number, obj: object) {
|
|
344
|
+
peer.sendChain = peer.sendChain.then(async () => {
|
|
345
|
+
if (dc.readyState !== "open" || !peer.keyH2C || !peer.th) return;
|
|
346
|
+
let frame: ArrayBuffer;
|
|
347
|
+
try {
|
|
348
|
+
frame = await e2eSeal(peer.keyH2C, peer.send, flags, peer.th, packEnvelope(obj));
|
|
349
|
+
} catch {
|
|
350
|
+
closePeer(peerId); // counter overflow — fail closed
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
dc.send(frame);
|
|
355
|
+
} catch {
|
|
356
|
+
/* peer vanished mid-send; dropping the frame is correct */
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return peer.sendChain;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Decrypt + route one inbound frame. Fail-closed: any decryption failure,
|
|
363
|
+
// replay, pre-confirmation app frame, or string frame closes the peer.
|
|
364
|
+
async function onFrame(peerId: string, dc: any, peer: Peer, data: any) {
|
|
365
|
+
if (!peers.has(peerId)) return;
|
|
366
|
+
if (typeof data === "string" || !peer.keyC2H || !peer.th) return closePeer(peerId);
|
|
367
|
+
let env: any;
|
|
235
368
|
try {
|
|
236
|
-
|
|
369
|
+
const { plaintext } = await e2eOpen(peer.keyC2H, data, peer.th, peer.recv);
|
|
370
|
+
env = unpackEnvelope(plaintext);
|
|
237
371
|
} catch {
|
|
238
|
-
|
|
372
|
+
return closePeer(peerId); // bad version/epoch/tag/AAD or replay
|
|
373
|
+
}
|
|
374
|
+
if (!peer.confirmed) {
|
|
375
|
+
if (!env || env.t !== "confirm") return closePeer(peerId);
|
|
376
|
+
if (typeof env.nonce === "string" && !peer.confirmedOut) {
|
|
377
|
+
// Flush our echo before marking confirmed-out (so no app frame is acted on
|
|
378
|
+
// until the peer can also complete its side).
|
|
379
|
+
await enqueueSeal(peerId, dc, peer, FLAG_CONFIRM, {
|
|
380
|
+
t: "confirm",
|
|
381
|
+
nonce: peer.myNonce,
|
|
382
|
+
echo: env.nonce,
|
|
383
|
+
});
|
|
384
|
+
peer.confirmedOut = true;
|
|
385
|
+
}
|
|
386
|
+
if (env.echo && env.echo === peer.myNonce) peer.confirmedIn = true;
|
|
387
|
+
if (peer.confirmedIn && peer.confirmedOut) {
|
|
388
|
+
peer.confirmed = true;
|
|
389
|
+
if (peer.confirmTimer) clearTimeout(peer.confirmTimer);
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
239
392
|
}
|
|
393
|
+
if (!env || env.t === "confirm") return; // stray confirm after handshake — ignore
|
|
394
|
+
onReq(peerId, dc, peer, env);
|
|
240
395
|
}
|
|
241
396
|
|
|
242
|
-
async function onReq(dc: any,
|
|
397
|
+
async function onReq(peerId: string, dc: any, peer: Peer, req: any) {
|
|
243
398
|
if (req.t === "abort") {
|
|
244
|
-
aborts.get(req.id)?.abort();
|
|
245
|
-
aborts.delete(req.id);
|
|
399
|
+
peer.aborts.get(req.id)?.abort();
|
|
400
|
+
peer.aborts.delete(req.id);
|
|
246
401
|
return;
|
|
247
402
|
}
|
|
248
403
|
if (req.t !== "req") return;
|
|
249
404
|
const { id, method, path: p, body } = req;
|
|
250
405
|
const ac = new AbortController();
|
|
251
|
-
aborts.set(id, ac);
|
|
406
|
+
peer.aborts.set(id, ac);
|
|
252
407
|
try {
|
|
253
408
|
// The host part is a placeholder — the handler only routes on the path.
|
|
254
409
|
const res = await opts.localFetch(
|
|
@@ -262,21 +417,39 @@ export async function startShare(
|
|
|
262
417
|
signal: ac.signal,
|
|
263
418
|
}),
|
|
264
419
|
);
|
|
265
|
-
|
|
420
|
+
enqueueSeal(peerId, dc, peer, 0, {
|
|
421
|
+
t: "res",
|
|
422
|
+
id,
|
|
423
|
+
status: res.status,
|
|
424
|
+
ct: res.headers.get("content-type") ?? "",
|
|
425
|
+
});
|
|
266
426
|
const reader = res.body!.getReader();
|
|
267
427
|
const dec = new TextDecoder();
|
|
428
|
+
let seq = 0;
|
|
268
429
|
for (;;) {
|
|
269
430
|
const { done, value } = await reader.read();
|
|
270
431
|
if (done) break;
|
|
271
432
|
const text = dec.decode(value, { stream: true });
|
|
433
|
+
// Slice on UTF-16 boundaries: JSON round-trips lone surrogates as \uXXXX,
|
|
434
|
+
// so the receiver reassembles the exact text by concatenating in seq order.
|
|
272
435
|
for (let i = 0; i < text.length; i += MAX_CHUNK)
|
|
273
|
-
|
|
436
|
+
enqueueSeal(peerId, dc, peer, 0, {
|
|
437
|
+
t: "data",
|
|
438
|
+
id,
|
|
439
|
+
seq: seq++,
|
|
440
|
+
chunk: text.slice(i, i + MAX_CHUNK),
|
|
441
|
+
});
|
|
274
442
|
}
|
|
275
|
-
|
|
443
|
+
enqueueSeal(peerId, dc, peer, 0, { t: "end", id, seq });
|
|
276
444
|
} catch (e) {
|
|
277
|
-
if ((e as Error).name !== "AbortError")
|
|
445
|
+
if ((e as Error).name !== "AbortError")
|
|
446
|
+
enqueueSeal(peerId, dc, peer, 0, {
|
|
447
|
+
t: "end",
|
|
448
|
+
id,
|
|
449
|
+
error: String((e as Error).message ?? e),
|
|
450
|
+
});
|
|
278
451
|
} finally {
|
|
279
|
-
aborts.delete(id);
|
|
452
|
+
peer.aborts.delete(id);
|
|
280
453
|
}
|
|
281
454
|
}
|
|
282
455
|
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import "./ts-VrgyWwNH.js";
|
|
2
|
-
import "./logger-B9h0djqx.js";
|
|
3
|
-
import "./versionChecker-BjZOppZJ.js";
|
|
4
|
-
import "./pidStore-B5vBu8Px.js";
|
|
5
|
-
import "./globalPidIndex-gZuTvTBs.js";
|
|
6
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DwPmzY8B.js";
|
|
7
|
-
|
|
8
|
-
export { SUPPORTED_CLIS };
|