@saadnvd1/agent-os 0.3.694 → 0.3.698

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.
Files changed (38) hide show
  1. package/app/api/peer/ping/route.ts +10 -0
  2. package/components/Settings/NoApprovalsSection.tsx +1 -1
  3. package/lib/__fixtures__/connect-transport.ts +93 -0
  4. package/lib/__fixtures__/peer-machine.ts +45 -0
  5. package/lib/__fixtures__/peer-rpc.ts +224 -0
  6. package/lib/approvals/enable.ts +1 -21
  7. package/lib/approvals/no-approvals.ts +1 -1
  8. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  9. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  10. package/lib/db/types.ts +2 -0
  11. package/lib/hosts/index.ts +28 -7
  12. package/lib/hosts/peer-socket.ts +3 -1
  13. package/lib/hosts/read-capped-bytes.ts +23 -0
  14. package/lib/hosts/remote-api.ts +112 -41
  15. package/lib/models/plan-step.ts +5 -13
  16. package/lib/peer/allowlist.ts +136 -0
  17. package/lib/peer/audit.ts +144 -0
  18. package/lib/peer/client.ts +188 -0
  19. package/lib/peer/envelope.ts +424 -0
  20. package/lib/peer/internal.ts +22 -0
  21. package/lib/peer/keys.ts +122 -0
  22. package/lib/peer/links.ts +96 -0
  23. package/lib/peer/nonce-store.ts +23 -0
  24. package/lib/peer/replay.ts +81 -0
  25. package/lib/peer/rpc-intake.ts +229 -0
  26. package/lib/peer/rpc-server.ts +351 -0
  27. package/lib/security/auth.ts +42 -5
  28. package/lib/security/gate.ts +35 -12
  29. package/lib/security/route-guard.ts +3 -1
  30. package/lib/session-artifacts/peer.ts +12 -1
  31. package/lib/tasks/contract/authorize.ts +4 -0
  32. package/lib/tasks/contract/plan.ts +1 -14
  33. package/lib/worktree-sweep/testTemp.ts +3 -0
  34. package/package.json +1 -1
  35. package/scripts/install-deps +6 -1
  36. package/scripts/lib/commands.sh +1 -1
  37. package/scripts/lib/wsl.test.ts +51 -1
  38. package/server.ts +18 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The keys a machine uses with its linked peers. The signing key is
3
+ * Connect's machine.key (Ed25519); the box key (X25519) is peer.key next to
4
+ * it, made on first use, owner-only, and never sent to the Connect service.
5
+ * Public keys travel as base64url of their 32 raw bytes.
6
+ */
7
+
8
+ import crypto, { type KeyObject } from "crypto";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { connectDir, readConnect } from "../connect/config";
12
+
13
+ const RAW = /^[A-Za-z0-9_-]{43}$/;
14
+
15
+ export const rawPublic = (key: KeyObject): string => {
16
+ const jwk = key.export({ format: "jwk" });
17
+ if (typeof jwk.x !== "string") throw new Error("not an OKP key");
18
+ return jwk.x;
19
+ };
20
+
21
+ function publicFromRaw(raw: string, crv: "Ed25519" | "X25519"): KeyObject {
22
+ if (!RAW.test(raw)) throw new Error(`bad ${crv} public key`);
23
+ const key = crypto.createPublicKey({
24
+ key: { kty: "OKP", crv, x: raw },
25
+ format: "jwk",
26
+ });
27
+ if (key.asymmetricKeyType !== crv.toLowerCase())
28
+ throw new Error(`bad ${crv} public key`);
29
+ return key;
30
+ }
31
+
32
+ export const signPublic = (raw: string) => publicFromRaw(raw, "Ed25519");
33
+ export const boxPublic = (raw: string) => publicFromRaw(raw, "X25519");
34
+
35
+ export interface BoxKey {
36
+ privateKey: KeyObject;
37
+ publicRaw: string;
38
+ }
39
+
40
+ /**
41
+ * Makes sure `p` is this user's and nobody else's: repairs group or other
42
+ * access, refuses one owned by someone else.
43
+ */
44
+ function ownerOnly(p: string, mode: number) {
45
+ if (process.platform === "win32") return;
46
+ const st = fs.lstatSync(p);
47
+ if (st.isSymbolicLink()) throw new Error(`${p} is a symlink`);
48
+ if (typeof process.getuid === "function" && st.uid !== process.getuid())
49
+ throw new Error(`${p} belongs to another user`);
50
+ if (st.mode & 0o077) fs.chmodSync(p, mode);
51
+ }
52
+
53
+ /** peer.key, created owner-only if it isn't there yet. */
54
+ export function loadBoxKey(dir = connectDir()): BoxKey {
55
+ const file = path.join(dir, "peer.key");
56
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
57
+ ownerOnly(dir, 0o700);
58
+ if (!fs.existsSync(file)) {
59
+ const { privateKey } = crypto.generateKeyPairSync("x25519");
60
+ const pem = privateKey.export({ format: "pem", type: "pkcs8" });
61
+ // Written whole under a temp name, then linked in: a crash never leaves
62
+ // a half-written key, and two starts racing keep whichever came first.
63
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
64
+ fs.writeFileSync(tmp, pem, { mode: 0o600, flag: "wx" });
65
+ try {
66
+ fs.linkSync(tmp, file);
67
+ } catch (err) {
68
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
69
+ } finally {
70
+ fs.rmSync(tmp, { force: true });
71
+ }
72
+ }
73
+ ownerOnly(file, 0o600);
74
+ const privateKey = crypto.createPrivateKey(fs.readFileSync(file, "utf8"));
75
+ if (privateKey.asymmetricKeyType !== "x25519")
76
+ throw new Error(`${file} is not an X25519 key`);
77
+ return {
78
+ privateKey,
79
+ publicRaw: rawPublic(crypto.createPublicKey(privateKey)),
80
+ };
81
+ }
82
+
83
+ export interface Identity {
84
+ machineId: string;
85
+ signKey: KeyObject;
86
+ signPub: string;
87
+ }
88
+
89
+ /** This machine as Connect knows it, or null when it isn't enrolled. */
90
+ export function loadIdentity(dir = connectDir()): Identity | null {
91
+ const read = readConnect(dir);
92
+ if (!read.ok) return null;
93
+ const signKey = crypto.createPrivateKey(read.files.machineKey);
94
+ if (signKey.asymmetricKeyType !== "ed25519") return null;
95
+ return {
96
+ machineId: read.files.config.machineId,
97
+ signKey,
98
+ signPub: rawPublic(crypto.createPublicKey(signKey)),
99
+ };
100
+ }
101
+
102
+ const g = globalThis as unknown as {
103
+ __agentosPeerIdentity?: {
104
+ at: number;
105
+ dir: string;
106
+ identity: Identity | null;
107
+ };
108
+ };
109
+
110
+ /** loadIdentity, re-read at most every 30 s (it's on every peer call). */
111
+ export function currentIdentity(dir = connectDir()): Identity | null {
112
+ const c = g.__agentosPeerIdentity;
113
+ if (c && c.dir === dir && Date.now() - c.at < 30_000) return c.identity;
114
+ let identity: Identity | null = null;
115
+ try {
116
+ identity = loadIdentity(dir);
117
+ } catch {
118
+ identity = null;
119
+ }
120
+ g.__agentosPeerIdentity = { at: Date.now(), dir, identity };
121
+ return identity;
122
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pinned peers: a machine linked through Connect, by its Connect id, with
3
+ * the keys it paired with. Nothing here talks to the network.
4
+ */
5
+
6
+ import { getDb } from "../db";
7
+
8
+ export type PinState = "active" | "suspended";
9
+
10
+ export interface PeerPin {
11
+ hostId: string;
12
+ machineId: string;
13
+ hostname: string;
14
+ signPub: string;
15
+ boxPub: string;
16
+ state: PinState;
17
+ suspendedReason: string | null;
18
+ }
19
+
20
+ interface Row {
21
+ host_id: string;
22
+ machine_id: string;
23
+ hostname: string;
24
+ sign_pub: string;
25
+ box_pub: string;
26
+ state: string;
27
+ suspended_reason: string | null;
28
+ }
29
+
30
+ const toPin = (r: Row | undefined): PeerPin | null =>
31
+ r
32
+ ? {
33
+ hostId: r.host_id,
34
+ machineId: r.machine_id,
35
+ hostname: r.hostname,
36
+ signPub: r.sign_pub,
37
+ boxPub: r.box_pub,
38
+ state: r.state === "active" ? "active" : "suspended",
39
+ suspendedReason: r.suspended_reason,
40
+ }
41
+ : null;
42
+
43
+ // Only a host created as a Connect machine counts: a pin on any other row
44
+ // would be a second way in that nothing shows.
45
+ const SELECT = `SELECT p.* FROM peer_links p JOIN hosts h ON h.id = p.host_id
46
+ WHERE h.kind = 'connect'`;
47
+
48
+ export const pinForMachine = (machineId: string): PeerPin | null =>
49
+ toPin(
50
+ getDb().prepare(`${SELECT} AND p.machine_id = ?`).get(machineId) as
51
+ Row | undefined
52
+ );
53
+
54
+ /** Whether any linked machine may call in at all. */
55
+ export const hasActivePin = (): boolean =>
56
+ getDb().prepare(`${SELECT} AND p.state = 'active' LIMIT 1`).get() !==
57
+ undefined;
58
+
59
+ export const pinForHost = (hostId: string): PeerPin | null =>
60
+ toPin(
61
+ getDb().prepare(`${SELECT} AND p.host_id = ?`).get(hostId) as
62
+ Row | undefined
63
+ );
64
+
65
+ /** Pins a peer's keys to a Connect host. Pairing (S2) is the only caller. */
66
+ export function savePin(pin: Omit<PeerPin, "state" | "suspendedReason">) {
67
+ getDb()
68
+ .prepare(
69
+ `INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
70
+ VALUES (?, ?, ?, ?, ?)
71
+ ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
72
+ hostname = excluded.hostname, sign_pub = excluded.sign_pub,
73
+ box_pub = excluded.box_pub, state = 'active', suspended_reason = NULL,
74
+ paired_at = datetime('now'), last_error = NULL`
75
+ )
76
+ .run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
77
+ }
78
+
79
+ export function suspendPin(hostId: string, reason: string) {
80
+ getDb()
81
+ .prepare(
82
+ `UPDATE peer_links SET state = 'suspended', suspended_reason = ? WHERE host_id = ?`
83
+ )
84
+ .run(reason, hostId);
85
+ }
86
+
87
+ /** Last outcome of a call to that peer, for Machines. */
88
+ export function notePinResult(hostId: string, error: string | null) {
89
+ getDb()
90
+ .prepare(
91
+ error
92
+ ? `UPDATE peer_links SET last_error = ? WHERE host_id = ?`
93
+ : `UPDATE peer_links SET last_ok_at = datetime('now'), last_error = NULL WHERE host_id = ?`
94
+ )
95
+ .run(...(error ? [error.slice(0, 300), hostId] : [hostId]));
96
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The replay guard's nonces in SQLite, so they outlive a restart. One
3
+ * statement both checks and records, so two requests carrying the same nonce
4
+ * can't both pass.
5
+ */
6
+
7
+ import { getDb } from "../db";
8
+ import type { NonceStore } from "./replay";
9
+
10
+ export function sqliteNonceStore(db = getDb): NonceStore {
11
+ return {
12
+ claim: (peer, nonce, until, now) =>
13
+ db()
14
+ .prepare(
15
+ `INSERT INTO peer_nonces (machine_id, nonce, until) VALUES (?, ?, ?)
16
+ ON CONFLICT (machine_id, nonce) DO UPDATE SET until = excluded.until
17
+ WHERE peer_nonces.until < ?`
18
+ )
19
+ .run(peer.slice(0, 64), nonce, until, now).changes === 1,
20
+ prune: (now) =>
21
+ void db().prepare(`DELETE FROM peer_nonces WHERE until < ?`).run(now),
22
+ };
23
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Replay protection for peer envelopes: a timestamp within ±120 s, and each
3
+ * peer's nonces remembered for as long as their timestamp would still pass.
4
+ * A nonce is only ever forgotten once it's outside that window; a peer that
5
+ * fills its cache inside the window is told it's busy rather than letting an
6
+ * older nonce be replayed.
7
+ *
8
+ * The server's guard also writes each nonce to a NonceStore (SQLite,
9
+ * nonce-store.ts) before the call runs, so a restart doesn't forget the
10
+ * nonces still inside the window. Memory is only a cache in front of it; if
11
+ * the store can't be written the call is refused as busy.
12
+ */
13
+
14
+ import { SKEW_MS } from "./envelope";
15
+
16
+ export type Freshness = "ok" | "stale" | "replay" | "busy";
17
+
18
+ export interface NonceStore {
19
+ /** Records the nonce until `until`; false if it's already recorded. */
20
+ claim(peer: string, nonce: string, until: number, now: number): boolean;
21
+ /** Forgets nonces whose time is up. */
22
+ prune(now: number): void;
23
+ }
24
+
25
+ export interface ReplayGuard {
26
+ check(peer: string, nonce: string, ts: number, now?: number): Freshness;
27
+ }
28
+
29
+ export function createReplayGuard(
30
+ opts: { windowMs?: number; maxPerPeer?: number; store?: NonceStore } = {}
31
+ ): ReplayGuard {
32
+ const { store } = opts;
33
+ const windowMs = opts.windowMs ?? SKEW_MS;
34
+ const maxPerPeer = opts.maxPerPeer ?? 50_000;
35
+ // nonce → the time after which its timestamp is stale anyway.
36
+ const seen = new Map<string, Map<string, number>>();
37
+ let sweptAt = 0;
38
+
39
+ const sweep = (nonces: Map<string, number>, now: number) => {
40
+ for (const [nonce, until] of nonces) if (until < now) nonces.delete(nonce);
41
+ };
42
+
43
+ return {
44
+ check(peer, nonce, ts, now = Date.now()) {
45
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return "stale";
46
+ if (now - sweptAt > windowMs) {
47
+ sweptAt = now;
48
+ for (const nonces of seen.values()) sweep(nonces, now);
49
+ try {
50
+ store?.prune(now);
51
+ } catch {
52
+ // pruned again next window
53
+ }
54
+ }
55
+ let nonces = seen.get(peer);
56
+ if (!nonces) seen.set(peer, (nonces = new Map()));
57
+ const until = nonces.get(nonce);
58
+ if (until !== undefined && until >= now) return "replay";
59
+ if (nonces.size >= maxPerPeer) {
60
+ sweep(nonces, now);
61
+ if (nonces.size >= maxPerPeer) return "busy";
62
+ }
63
+ // Past ts + window the timestamp check refuses it on its own.
64
+ const expires = ts + windowMs;
65
+ if (store) {
66
+ let fresh: boolean;
67
+ try {
68
+ fresh = store.claim(peer, nonce, expires, now);
69
+ } catch {
70
+ return "busy";
71
+ }
72
+ if (!fresh) {
73
+ nonces.set(nonce, expires);
74
+ return "replay";
75
+ }
76
+ }
77
+ nonces.set(nonce, expires);
78
+ return "ok";
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Reading an envelope off the wire: how much memory envelopes may hold
3
+ * before and after their sender is proven, and how slowly one may arrive.
4
+ * The sender is proven by the header's own signature, before any of the
5
+ * body is held for it. Every sender, linked or not, goes through exactly
6
+ * this, so neither the time to a refusal nor a `busy` says whether a machine
7
+ * is linked here.
8
+ */
9
+
10
+ import type { IncomingMessage } from "http";
11
+ import { isIP } from "net";
12
+ import { isTunnelSocket } from "../security/auth";
13
+ import {
14
+ HEADER_SIG,
15
+ MAX_HEADER,
16
+ MAX_REQUEST_ENVELOPE,
17
+ type Refusal,
18
+ } from "./envelope";
19
+
20
+ /**
21
+ * One request's share of the budget. Bytes are held against its source until
22
+ * prove() names the sender whose header signature held, then against that
23
+ * sender; close() gives back everything it took.
24
+ */
25
+ export interface Ticket {
26
+ take(bytes: number): boolean;
27
+ prove(peer: string): boolean;
28
+ close(): void;
29
+ }
30
+
31
+ /**
32
+ * Bytes held by envelopes still arriving. Before the header's signature is
33
+ * checked they count against the source address, in a small pool of their
34
+ * own, so strangers can't touch what linked machines get; after, against
35
+ * the proven sender. Each share is well below its total, and one source may
36
+ * have only a few requests open.
37
+ */
38
+ export interface BodyBudget {
39
+ open(source: string): Ticket | null;
40
+ }
41
+
42
+ export interface BudgetLimits {
43
+ /** Bytes of proven senders' envelopes, in all and per sender. */
44
+ total: number;
45
+ perPeer: number;
46
+ /** Bytes not yet proven, in all and per source. */
47
+ unverifiedTotal: number;
48
+ perSource: number;
49
+ requestsPerSource: number;
50
+ }
51
+
52
+ export const DEFAULT_BUDGET: BudgetLimits = {
53
+ total: 4 * MAX_REQUEST_ENVELOPE,
54
+ perPeer: MAX_REQUEST_ENVELOPE,
55
+ unverifiedTotal: 4 * 1024 * 1024,
56
+ perSource: 256 * 1024,
57
+ requestsPerSource: 4,
58
+ };
59
+
60
+ export function createBodyBudget(o: Partial<BudgetLimits> = {}): BodyBudget {
61
+ const lim = { ...DEFAULT_BUDGET, ...o };
62
+ let proven = 0;
63
+ let unproven = 0;
64
+ const byPeer = new Map<string, number>();
65
+ const bySource = new Map<string, number>();
66
+ const open = new Map<string, number>();
67
+ const add = (m: Map<string, number>, k: string, n: number) => {
68
+ const v = (m.get(k) ?? 0) + n;
69
+ if (v > 0) m.set(k, v);
70
+ else m.delete(k);
71
+ };
72
+ return {
73
+ open(source) {
74
+ if ((open.get(source) ?? 0) >= lim.requestsPerSource) return null;
75
+ add(open, source, 1);
76
+ let peer: string | null = null;
77
+ let held = 0;
78
+ let closed = false;
79
+ const fits = (who: string | null, bytes: number) =>
80
+ who === null
81
+ ? unproven + bytes <= lim.unverifiedTotal &&
82
+ (bySource.get(source) ?? 0) + bytes <= lim.perSource
83
+ : proven + bytes <= lim.total &&
84
+ (byPeer.get(who) ?? 0) + bytes <= lim.perPeer;
85
+ const charge = (who: string | null, bytes: number) => {
86
+ if (who === null) {
87
+ unproven += bytes;
88
+ add(bySource, source, bytes);
89
+ } else {
90
+ proven += bytes;
91
+ add(byPeer, who, bytes);
92
+ }
93
+ };
94
+ return {
95
+ take(bytes) {
96
+ if (closed || !fits(peer, bytes)) return false;
97
+ charge(peer, bytes);
98
+ held += bytes;
99
+ return true;
100
+ },
101
+ prove(who) {
102
+ if (closed || peer !== null || !fits(who, held)) return false;
103
+ charge(null, -held);
104
+ charge(who, held);
105
+ peer = who;
106
+ return true;
107
+ },
108
+ close() {
109
+ if (closed) return;
110
+ closed = true;
111
+ charge(peer, -held);
112
+ add(open, source, -1);
113
+ },
114
+ };
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Where a request came from: the visitor's address Cloudflare stamps on a
121
+ * tunnel request (the socket there is always cloudflared), else the socket's.
122
+ */
123
+ export function sourceOf(req: IncomingMessage): string {
124
+ if (isTunnelSocket(req.socket)) {
125
+ const cf = String(req.headers["cf-connecting-ip"] ?? "").trim();
126
+ if (isIP(cf)) return cf;
127
+ }
128
+ return req.socket.remoteAddress ?? "unknown";
129
+ }
130
+
131
+ export interface Pace {
132
+ /** How long the first bytes may take before a rate is required. */
133
+ graceMs: number;
134
+ /** Bytes per second the body must keep up after the grace period, */
135
+ minRate: number;
136
+ /** measured over each window this long, so a burst can't bank time. */
137
+ windowMs: number;
138
+ }
139
+
140
+ export const DEFAULT_PACE: Pace = {
141
+ graceMs: 10_000,
142
+ minRate: 512 * 1024,
143
+ windowMs: 5_000,
144
+ };
145
+
146
+ export type Read =
147
+ | { body: Buffer }
148
+ | { refusal: Refusal }
149
+ | { tooLarge: true }
150
+ | { tooSlow: true };
151
+
152
+ /**
153
+ * The body, at most MAX_REQUEST_ENVELOPE bytes. `early` sees the header and
154
+ * its signature as soon as they've arrived, and returns the proven sender or
155
+ * a refusal. Every byte held must be granted by the ticket, or reading stops
156
+ * as busy; a body slower than `pace` stops as too slow.
157
+ */
158
+ export function readEnvelope(
159
+ req: IncomingMessage,
160
+ o: {
161
+ early: (
162
+ headerBytes: Buffer,
163
+ headerSig: Buffer
164
+ ) => { peer: string } | { refusal: Refusal };
165
+ ticket: Ticket;
166
+ pace: Pace;
167
+ }
168
+ ): Promise<Read> {
169
+ return new Promise((resolve, reject) => {
170
+ if (Number(req.headers["content-length"]) > MAX_REQUEST_ENVELOPE)
171
+ return resolve({ tooLarge: true });
172
+ const chunks: Buffer[] = [];
173
+ const started = Date.now();
174
+ let total = 0;
175
+ let proven = false;
176
+ let done = false;
177
+ const finish = (v: Read | Error) => {
178
+ if (done) return;
179
+ done = true;
180
+ clearInterval(timer);
181
+ // An unread rest is dropped with the connection (Connection: close).
182
+ req.removeAllListeners("data");
183
+ if (v instanceof Error) reject(v);
184
+ else resolve(v);
185
+ };
186
+ const { graceMs, minRate, windowMs } = o.pace;
187
+ // (time, bytes so far), back to the start of the current window.
188
+ const samples = [{ t: started, total: 0 }];
189
+ const timer = setInterval(
190
+ () => {
191
+ const now = Date.now();
192
+ samples.push({ t: now, total });
193
+ while (samples.length > 1 && samples[1].t <= now - windowMs)
194
+ samples.shift();
195
+ if (now - started <= graceMs) return;
196
+ const base = samples[0];
197
+ if (total - base.total < ((now - base.t) / 1000) * minRate)
198
+ finish({ tooSlow: true });
199
+ },
200
+ Math.max(25, Math.min(1000, graceMs / 4, windowMs / 4))
201
+ );
202
+ req.on("data", (chunk: Buffer) => {
203
+ if (done) return;
204
+ total += chunk.length;
205
+ if (total > MAX_REQUEST_ENVELOPE) return finish({ tooLarge: true });
206
+ if (!o.ticket.take(chunk.length)) return finish({ refusal: "busy" });
207
+ chunks.push(chunk);
208
+ if (proven || total < 4) return;
209
+ const head = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
210
+ chunks.splice(0, chunks.length, head);
211
+ const len = head.readUInt32BE(0);
212
+ if (len > MAX_HEADER) return finish({ refusal: "bad_envelope" });
213
+ if (head.length < 4 + len + HEADER_SIG) return;
214
+ const seen = o.early(
215
+ head.subarray(4, 4 + len),
216
+ head.subarray(4 + len, 4 + len + HEADER_SIG)
217
+ );
218
+ if ("refusal" in seen) return finish({ refusal: seen.refusal });
219
+ if (!o.ticket.prove(seen.peer)) return finish({ refusal: "busy" });
220
+ proven = true;
221
+ });
222
+ req.on("end", () => {
223
+ if (total >= 4 && !proven && !done)
224
+ return finish({ refusal: "bad_envelope" });
225
+ finish({ body: Buffer.concat(chunks) });
226
+ });
227
+ req.on("error", (err) => finish(err));
228
+ });
229
+ }