@saadnvd1/agent-os 0.3.697 → 0.3.700

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 (88) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peer/ping/route.ts +10 -0
  7. package/app/api/peers/account/route.ts +9 -0
  8. package/app/api/peers/gate.test.ts +111 -0
  9. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  10. package/app/api/peers/link/[id]/route.ts +20 -0
  11. package/app/api/peers/link/route.ts +23 -0
  12. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  13. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  14. package/app/api/peers/pending/route.ts +7 -0
  15. package/app/api/sessions/route.ts +18 -1
  16. package/app/page.tsx +2 -0
  17. package/components/Hosts/AccountMachines.tsx +180 -0
  18. package/components/Hosts/HostRow.tsx +44 -35
  19. package/components/Hosts/HostsDialog.tsx +49 -37
  20. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  21. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  22. package/components/Hosts/sheet.ts +3 -0
  23. package/components/SessionList/RowSubtitle.tsx +11 -1
  24. package/components/Welcome/ConnectBlock.tsx +1 -1
  25. package/data/peers/index.ts +2 -0
  26. package/data/peers/keys.ts +6 -0
  27. package/data/peers/queries.ts +141 -0
  28. package/lib/__fixtures__/connect-transport.ts +93 -0
  29. package/lib/__fixtures__/peer-machine.ts +45 -0
  30. package/lib/__fixtures__/peer-rpc.ts +224 -0
  31. package/lib/agents/brief.ts +3 -1
  32. package/lib/bus/format.ts +25 -4
  33. package/lib/bus/index.ts +113 -106
  34. package/lib/bus/resolve.ts +16 -2
  35. package/lib/bus/targets.ts +122 -0
  36. package/lib/bus/waits/kick.ts +12 -0
  37. package/lib/bus/waits/lines.ts +69 -0
  38. package/lib/bus/waits/register.ts +83 -0
  39. package/lib/bus/waits/store.ts +196 -0
  40. package/lib/bus/waits/tick.ts +192 -0
  41. package/lib/connect/control-api.ts +108 -2
  42. package/lib/connect/heartbeat.ts +11 -3
  43. package/lib/connect/serve.ts +2 -0
  44. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  45. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  46. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  47. package/lib/db/types.ts +6 -0
  48. package/lib/done/archive.ts +2 -0
  49. package/lib/hosts/index.ts +28 -7
  50. package/lib/hosts/peer-socket.ts +3 -1
  51. package/lib/hosts/read-capped-bytes.ts +23 -0
  52. package/lib/hosts/remote-api.ts +112 -41
  53. package/lib/orchestrator/describe.ts +13 -0
  54. package/lib/orchestrator/facts.ts +17 -0
  55. package/lib/peer/account.ts +198 -0
  56. package/lib/peer/allowlist.ts +136 -0
  57. package/lib/peer/audit.ts +144 -0
  58. package/lib/peer/cli.ts +24 -0
  59. package/lib/peer/client.ts +188 -0
  60. package/lib/peer/code.ts +3 -0
  61. package/lib/peer/envelope.ts +424 -0
  62. package/lib/peer/hello-route.ts +32 -0
  63. package/lib/peer/hello.ts +346 -0
  64. package/lib/peer/internal.ts +22 -0
  65. package/lib/peer/keys.ts +122 -0
  66. package/lib/peer/link-cli.ts +233 -0
  67. package/lib/peer/links.ts +96 -0
  68. package/lib/peer/machines.ts +72 -0
  69. package/lib/peer/nonce-store.ts +23 -0
  70. package/lib/peer/notify.ts +32 -0
  71. package/lib/peer/pairing-out.ts +382 -0
  72. package/lib/peer/pairing.ts +403 -0
  73. package/lib/peer/pin.ts +55 -0
  74. package/lib/peer/replay.ts +81 -0
  75. package/lib/peer/rpc-intake.ts +258 -0
  76. package/lib/peer/rpc-server.ts +369 -0
  77. package/lib/schedules/when.ts +1 -1
  78. package/lib/security/auth.ts +51 -6
  79. package/lib/security/gate.ts +38 -12
  80. package/lib/security/route-guard.ts +3 -1
  81. package/lib/session-artifacts/peer.ts +12 -1
  82. package/lib/sessions/end.ts +3 -0
  83. package/lib/tasks/contract/authorize.ts +4 -0
  84. package/lib/worktree-sweep/testTemp.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/agent-os +2 -0
  87. package/scripts/lib/commands.sh +2 -0
  88. package/server.ts +21 -0
@@ -0,0 +1,258 @@
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
+ * An IPv6 address counts by its /64: one host is usually handed a whole one.
123
+ */
124
+ export function sourceOf(req: IncomingMessage): string {
125
+ let addr = req.socket.remoteAddress ?? "unknown";
126
+ if (isTunnelSocket(req.socket)) {
127
+ const cf = String(req.headers["cf-connecting-ip"] ?? "").trim();
128
+ if (isIP(cf)) addr = cf;
129
+ }
130
+ return isIP(addr) === 6 ? ipv6Prefix64(addr) : addr;
131
+ }
132
+
133
+ /** The first four groups of an IPv6 address, expanded: its /64. */
134
+ export function ipv6Prefix64(addr: string): string {
135
+ const plain = addr.split("%")[0].toLowerCase();
136
+ if (/^::ffff:\d+\.\d+\.\d+\.\d+$/.test(plain)) return plain.slice(7);
137
+ const [head, tail = ""] = plain.split("::");
138
+ const a = head ? head.split(":") : [];
139
+ const b = plain.includes("::") && tail ? tail.split(":") : [];
140
+ // An embedded IPv4 tail takes two groups; it never reaches the first four
141
+ // unless the address is mostly zeros, which then pads them.
142
+ const fill = Array(Math.max(0, 8 - a.length - b.length)).fill("0");
143
+ const groups = [...a, ...fill, ...b].slice(0, 4);
144
+ return `${groups.map((g) => g.replace(/^0+(?=.)/, "")).join(":")}::/64`;
145
+ }
146
+
147
+ export interface Pace {
148
+ /** How long the header and its signature may take: they fit in the
149
+ * first packet, so a sender that holds them back is stalling. */
150
+ headerMs: number;
151
+ /** How long the first bytes may take before a rate is required. */
152
+ graceMs: number;
153
+ /** Bytes per second the body must keep up after the grace period, */
154
+ minRate: number;
155
+ /** measured over each window this long, so a burst can't bank time. */
156
+ windowMs: number;
157
+ }
158
+
159
+ export const DEFAULT_PACE: Pace = {
160
+ headerMs: 3_000,
161
+ graceMs: 10_000,
162
+ minRate: 512 * 1024,
163
+ windowMs: 5_000,
164
+ };
165
+
166
+ export type Read =
167
+ | { body: Buffer }
168
+ | { refusal: Refusal }
169
+ | { tooLarge: true }
170
+ | { tooSlow: true };
171
+
172
+ /**
173
+ * The body, at most MAX_REQUEST_ENVELOPE bytes. `early` sees the header and
174
+ * its signature as soon as they've arrived, and returns the proven sender or
175
+ * a refusal. Every byte held must be granted by the ticket, or reading stops
176
+ * as busy; a body slower than `pace` stops as too slow.
177
+ */
178
+ export function readEnvelope(
179
+ req: IncomingMessage,
180
+ o: {
181
+ early: (
182
+ headerBytes: Buffer,
183
+ headerSig: Buffer
184
+ ) => { peer: string } | { refusal: Refusal };
185
+ ticket: Ticket;
186
+ pace: Pace;
187
+ }
188
+ ): Promise<Read> {
189
+ return new Promise((resolve, reject) => {
190
+ if (Number(req.headers["content-length"]) > MAX_REQUEST_ENVELOPE)
191
+ return resolve({ tooLarge: true });
192
+ const chunks: Buffer[] = [];
193
+ const started = Date.now();
194
+ let total = 0;
195
+ let proven = false;
196
+ let done = false;
197
+ const finish = (v: Read | Error) => {
198
+ if (done) return;
199
+ done = true;
200
+ clearInterval(timer);
201
+ // An unread rest is dropped with the connection (Connection: close).
202
+ req.removeAllListeners("data");
203
+ if (v instanceof Error) reject(v);
204
+ else resolve(v);
205
+ };
206
+ const { headerMs, graceMs, minRate, windowMs } = o.pace;
207
+ // (time, bytes so far), back to the start of the current window.
208
+ const samples = [{ t: started, total: 0 }];
209
+ const timer = setInterval(
210
+ () => {
211
+ const now = Date.now();
212
+ samples.push({ t: now, total });
213
+ while (samples.length > 1 && samples[1].t <= now - windowMs)
214
+ samples.shift();
215
+ if (!proven && now - started > headerMs)
216
+ return finish({ tooSlow: true });
217
+ if (now - started <= graceMs) return;
218
+ const base = samples[0];
219
+ if (total - base.total < ((now - base.t) / 1000) * minRate)
220
+ finish({ tooSlow: true });
221
+ },
222
+ Math.max(25, Math.min(1000, headerMs / 4, graceMs / 4, windowMs / 4))
223
+ );
224
+ req.on("data", (chunk: Buffer) => {
225
+ if (done) return;
226
+ total += chunk.length;
227
+ if (total > MAX_REQUEST_ENVELOPE) return finish({ tooLarge: true });
228
+ if (!o.ticket.take(chunk.length)) return finish({ refusal: "busy" });
229
+ chunks.push(chunk);
230
+ if (proven || total < 4) return;
231
+ const head = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
232
+ chunks.splice(0, chunks.length, head);
233
+ const len = head.readUInt32BE(0);
234
+ if (len > MAX_HEADER) return finish({ refusal: "bad_envelope" });
235
+ if (head.length < 4 + len + HEADER_SIG) return;
236
+ let seen;
237
+ try {
238
+ seen = o.early(
239
+ head.subarray(4, 4 + len),
240
+ head.subarray(4 + len, 4 + len + HEADER_SIG)
241
+ );
242
+ } catch {
243
+ // A throw here would escape the 'data' handler and take the server
244
+ // down; whatever failed (the database, say), the caller is told busy.
245
+ return finish({ refusal: "busy" });
246
+ }
247
+ if ("refusal" in seen) return finish({ refusal: seen.refusal });
248
+ if (!o.ticket.prove(seen.peer)) return finish({ refusal: "busy" });
249
+ proven = true;
250
+ });
251
+ req.on("end", () => {
252
+ if (total >= 4 && !proven && !done)
253
+ return finish({ refusal: "bad_envelope" });
254
+ finish({ body: Buffer.concat(chunks) });
255
+ });
256
+ req.on("error", (err) => finish(err));
257
+ });
258
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * POST /api/peer/rpc: a linked machine's call, served before Next on any
3
+ * listener (the envelope is the authentication, not where it came from).
4
+ *
5
+ * 0. with no active link, refuse before reading anything;
6
+ * 1. read the envelope under a byte budget and a minimum transfer rate
7
+ * (rpc-intake.ts), proving the sender from the header's own signature
8
+ * before any of the body is held for it;
9
+ * 2. verify and decrypt it against the sender's pinned keys (envelope.ts);
10
+ * 3. match the inner method, path and query against the allowlist;
11
+ * 4. re-issue it to this machine's own loopback listener with the per-boot
12
+ * secret, so the gate stamps `peer` trust and every route and guard runs
13
+ * exactly as it does for any other caller.
14
+ *
15
+ * Refusals before decryption are plain and fixed (`bad envelope`, `bad
16
+ * signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
17
+ * and a bad signature all read `bad signature`, so a stranger can't tell
18
+ * which machines are linked here; the audit keeps the difference. Everything
19
+ * after is inside the envelope,
20
+ * always HTTP 200, so whoever carries the traffic never learns the route or
21
+ * its status.
22
+ */
23
+
24
+ import crypto from "crypto";
25
+ import http, { type IncomingMessage, type ServerResponse } from "http";
26
+ import { PEER_HEADER, PEER_INTERNAL_HEADER } from "../security/auth";
27
+ import { matchPeerRoute } from "./allowlist";
28
+ import { recordPeerCall, type AuditEntry, type Outcome } from "./audit";
29
+ import {
30
+ MAX_RESPONSE_BODY,
31
+ RPC_PATH,
32
+ SKEW_MS,
33
+ openRequest,
34
+ parseHeaderBytes,
35
+ sealResponse,
36
+ type PeerResponse,
37
+ type Refusal,
38
+ verifyHeader,
39
+ } from "./envelope";
40
+ import { peerSecret } from "./internal";
41
+ import {
42
+ currentIdentity,
43
+ loadBoxKey,
44
+ signPublic,
45
+ type BoxKey,
46
+ type Identity,
47
+ } from "./keys";
48
+ import { hasActivePin, pinForMachine, type PeerPin } from "./links";
49
+ import { sqliteNonceStore } from "./nonce-store";
50
+ import { createReplayGuard, type ReplayGuard } from "./replay";
51
+ import {
52
+ DEFAULT_PACE,
53
+ createBodyBudget,
54
+ readEnvelope,
55
+ sourceOf,
56
+ type BodyBudget,
57
+ type Pace,
58
+ } from "./rpc-intake";
59
+
60
+ export { createBodyBudget };
61
+
62
+ export { RPC_PATH };
63
+ const DISPATCH_TIMEOUT_MS = 10 * 60_000;
64
+
65
+ export interface Dispatch {
66
+ hostId: string;
67
+ method: string;
68
+ path: string;
69
+ query: string;
70
+ contentType: string;
71
+ body: Buffer;
72
+ }
73
+
74
+ export interface RpcDeps {
75
+ identity: () => Identity | null;
76
+ boxKey: () => BoxKey;
77
+ pinForMachine: (machineId: string) => PeerPin | null;
78
+ /** False while no link is active: the route is closed to everyone. */
79
+ anyActive: () => boolean;
80
+ replay: ReplayGuard;
81
+ dispatch: (d: Dispatch) => Promise<PeerResponse>;
82
+ audit: (e: AuditEntry) => void;
83
+ budget: BodyBudget;
84
+ pace?: Pace;
85
+ now?: () => number;
86
+ }
87
+
88
+ // Stands in for an unknown or suspended sender's key, so its envelope takes
89
+ // the same path, and the same time, to the same refusal as a bad signature.
90
+ const DECOY_KEY = crypto.generateKeyPairSync("ed25519").publicKey;
91
+
92
+ export const isPeerRpc = (req: IncomingMessage) =>
93
+ (req.url ?? "").split("?")[0] === RPC_PATH;
94
+
95
+ const REFUSED: Record<Refusal, [number, string, Outcome]> = {
96
+ bad_envelope: [400, "bad envelope", "bad_envelope"],
97
+ wrong_machine: [401, "bad signature", "bad_sig"],
98
+ unknown_peer: [401, "bad signature", "unknown_peer"],
99
+ suspended: [401, "bad signature", "suspended"],
100
+ bad_sig: [401, "bad signature", "bad_sig"],
101
+ stale: [401, "stale", "stale"],
102
+ replay: [401, "replay", "replay"],
103
+ busy: [429, "busy", "busy"],
104
+ };
105
+
106
+ function plain(res: ServerResponse, status: number, text: string) {
107
+ if (res.headersSent) return;
108
+ res.statusCode = status;
109
+ res.setHeader("Content-Type", "text/plain");
110
+ res.setHeader("Cache-Control", "no-store");
111
+ res.setHeader("Connection", "close");
112
+ res.end(text);
113
+ }
114
+
115
+ export async function handlePeerRpc(
116
+ req: IncomingMessage,
117
+ res: ServerResponse,
118
+ deps: RpcDeps
119
+ ): Promise<void> {
120
+ const started = Date.now();
121
+ if (req.method !== "POST") {
122
+ res.setHeader("Allow", "POST");
123
+ return plain(res, 405, "method not allowed");
124
+ }
125
+ const self = deps.identity();
126
+ let fromMachine: string | null = null;
127
+ let pin: PeerPin | null = null;
128
+ const audit = (
129
+ outcome: Outcome,
130
+ status: number,
131
+ route = RPC_PATH,
132
+ method = "POST",
133
+ bytes = 0
134
+ ) =>
135
+ deps.audit({
136
+ hostId: pin?.hostId ?? null,
137
+ machineId: fromMachine,
138
+ direction: "in",
139
+ method,
140
+ route,
141
+ status,
142
+ ms: Date.now() - started,
143
+ bytes,
144
+ outcome,
145
+ });
146
+ const refuse = (refusal: Refusal): null => {
147
+ const [status, text, outcome] = REFUSED[refusal];
148
+ audit(outcome, status);
149
+ plain(res, status, text);
150
+ return null;
151
+ };
152
+ // The sender's pinned signing key, or a decoy that verifies nothing for an
153
+ // unknown or suspended one, so its refusal takes the same path and time
154
+ // as a bad signature; `hidden` keeps what the check would have said.
155
+ let hidden: "unknown_peer" | "suspended" | null = null;
156
+ const signKeyFor = (machineId: string) => {
157
+ fromMachine = machineId;
158
+ const found = (pin = deps.pinForMachine(machineId));
159
+ hidden = !found
160
+ ? "unknown_peer"
161
+ : found.state !== "active"
162
+ ? "suspended"
163
+ : null;
164
+ if (!found || hidden) return DECOY_KEY;
165
+ try {
166
+ return signPublic(found.signPub);
167
+ } catch {
168
+ hidden = "unknown_peer";
169
+ return DECOY_KEY;
170
+ }
171
+ };
172
+ const refuseHidden = (refusal: Refusal): null =>
173
+ refuse(refusal === "bad_sig" && hidden ? hidden : refusal);
174
+ const drop = (status: number, text: string, outcome: Outcome): null => {
175
+ audit(outcome, status);
176
+ plain(res, status, text);
177
+ return null;
178
+ };
179
+
180
+ // The one nonce the header claimed: the whole envelope must carry it.
181
+ let claimed: string | null = null;
182
+ const spent: ReplayGuard = {
183
+ check: (peer, nonce) =>
184
+ claimed !== null && claimed === `${peer}:${nonce}` ? "ok" : "replay",
185
+ };
186
+
187
+ if (!deps.anyActive()) return plain(res, 404, "not found");
188
+ const ticket = deps.budget.open(sourceOf(req));
189
+ if (!ticket) return void refuse("busy");
190
+ const readAndOpen = async () => {
191
+ let read;
192
+ try {
193
+ read = await readEnvelope(req, {
194
+ early: (headerBytes, headerSig) => {
195
+ const header = parseHeaderBytes(headerBytes);
196
+ if (!header) return { refusal: "bad_envelope" };
197
+ if (!self || header.to !== self.machineId)
198
+ return { refusal: "wrong_machine" };
199
+ if (!verifyHeader(headerBytes, headerSig, signKeyFor(header.from)))
200
+ return { refusal: "bad_sig" };
201
+ const now = deps.now?.() ?? Date.now();
202
+ if (Math.abs(now - header.ts) > SKEW_MS) return { refusal: "stale" };
203
+ // The nonce is spent the moment its header is proven, so a header
204
+ // seen on the wire can't be sent again with junk bodies to hold
205
+ // the sender's share while the first is still arriving.
206
+ const fresh = deps.replay.check(
207
+ header.from,
208
+ header.nonce,
209
+ header.ts,
210
+ now
211
+ );
212
+ if (fresh !== "ok") return { refusal: fresh };
213
+ claimed = `${header.from}:${header.nonce}`;
214
+ return { peer: header.from };
215
+ },
216
+ ticket,
217
+ pace: deps.pace ?? DEFAULT_PACE,
218
+ });
219
+ } catch {
220
+ return drop(400, "bad envelope", "bad_envelope");
221
+ }
222
+ if ("tooLarge" in read) return drop(413, "too large", "too_large");
223
+ if ("tooSlow" in read) return drop(408, "too slow", "too_slow");
224
+ if ("refusal" in read) return refuseHidden(read.refusal);
225
+ if (!self) return refuse("wrong_machine");
226
+ return openRequest(read.body, {
227
+ self: self.machineId,
228
+ boxKey: deps.boxKey().privateKey,
229
+ signKeyFor,
230
+ replay: spent,
231
+ now: deps.now?.(),
232
+ });
233
+ };
234
+ let opened;
235
+ try {
236
+ opened = await readAndOpen();
237
+ } finally {
238
+ ticket.close();
239
+ }
240
+ if (!opened || !self) return;
241
+ if (!opened.ok) return void refuseHidden(opened.refusal);
242
+ const host = pin as PeerPin | null;
243
+ if (!host) return void refuse("unknown_peer");
244
+
245
+ const answer = (response: PeerResponse, outcome: Outcome, route: string) => {
246
+ const sealed = sealResponse({
247
+ resKey: opened.resKey,
248
+ nonce: opened.nonce,
249
+ signKey: self.signKey,
250
+ response,
251
+ });
252
+ audit(
253
+ outcome,
254
+ response.status,
255
+ route,
256
+ opened.request.method.slice(0, 10),
257
+ opened.request.body.length + response.body.length
258
+ );
259
+ res.statusCode = 200;
260
+ res.setHeader("Content-Type", "application/octet-stream");
261
+ res.setHeader("Cache-Control", "no-store");
262
+ res.end(sealed);
263
+ };
264
+
265
+ const { request } = opened;
266
+ const match = matchPeerRoute(request.method, request.path, request.query);
267
+ if (!match.ok)
268
+ return answer(
269
+ jsonResponse(403, { error: "That route isn't open to linked machines." }),
270
+ "denied_route",
271
+ "(denied)"
272
+ );
273
+ let response: PeerResponse;
274
+ try {
275
+ response = await deps.dispatch({
276
+ hostId: host.hostId,
277
+ method: request.method,
278
+ path: match.path,
279
+ query: match.query,
280
+ contentType: request.contentType,
281
+ body: request.body,
282
+ });
283
+ } catch (err) {
284
+ response = jsonResponse(502, {
285
+ error:
286
+ `Couldn't run that here: ${err instanceof Error ? err.message : String(err)}`.slice(
287
+ 0,
288
+ 300
289
+ ),
290
+ });
291
+ }
292
+ answer(response, "ok", match.route);
293
+ }
294
+
295
+ const jsonResponse = (status: number, body: object): PeerResponse => ({
296
+ status,
297
+ contentType: "application/json",
298
+ body: Buffer.from(JSON.stringify(body)),
299
+ });
300
+
301
+ const CONTENT_TYPE = /^[\w.+-]+\/[\w.+-]+(;[ \w.+=\-"]*)?$/;
302
+
303
+ /** Re-issues a verified call to this machine's own loopback listener. */
304
+ export function loopbackDispatch(
305
+ port: number,
306
+ secret: () => string = peerSecret
307
+ ): (d: Dispatch) => Promise<PeerResponse> {
308
+ return (d) =>
309
+ new Promise((resolve, reject) => {
310
+ const contentType =
311
+ d.contentType.length <= 200 && CONTENT_TYPE.test(d.contentType)
312
+ ? d.contentType
313
+ : "application/octet-stream";
314
+ const out = http.request(
315
+ {
316
+ host: "127.0.0.1",
317
+ port,
318
+ method: d.method,
319
+ path: d.query ? `${d.path}?${d.query}` : d.path,
320
+ headers: {
321
+ host: `127.0.0.1:${port}`,
322
+ ...(d.body.length > 0 && { "content-type": contentType }),
323
+ "content-length": String(d.body.length),
324
+ [PEER_INTERNAL_HEADER]: secret(),
325
+ [PEER_HEADER]: d.hostId,
326
+ },
327
+ timeout: DISPATCH_TIMEOUT_MS,
328
+ },
329
+ (res) => {
330
+ const chunks: Buffer[] = [];
331
+ let total = 0;
332
+ res.on("data", (c: Buffer) => {
333
+ total += c.length;
334
+ if (total > MAX_RESPONSE_BODY) {
335
+ res.destroy();
336
+ return reject(new Error("its answer is too large"));
337
+ }
338
+ chunks.push(c);
339
+ });
340
+ res.on("end", () =>
341
+ resolve({
342
+ status: res.statusCode ?? 502,
343
+ contentType: String(res.headers["content-type"] ?? ""),
344
+ body: Buffer.concat(chunks),
345
+ })
346
+ );
347
+ res.on("error", reject);
348
+ }
349
+ );
350
+ out.on("timeout", () => out.destroy(new Error("timed out")));
351
+ out.on("error", reject);
352
+ out.end(d.body);
353
+ });
354
+ }
355
+
356
+ /** The real dependencies: this machine's keys, its pins and its listener. */
357
+ export function rpcDeps(port: number): RpcDeps {
358
+ let box: BoxKey | null = null;
359
+ return {
360
+ identity: () => currentIdentity(),
361
+ boxKey: () => (box ??= loadBoxKey()),
362
+ pinForMachine,
363
+ anyActive: hasActivePin,
364
+ replay: createReplayGuard({ store: sqliteNonceStore() }),
365
+ dispatch: loopbackDispatch(port),
366
+ audit: recordPeerCall,
367
+ budget: createBodyBudget(),
368
+ };
369
+ }
@@ -26,7 +26,7 @@ const UNIT_MS: Record<string, number> = {
26
26
  d: 86_400_000,
27
27
  };
28
28
 
29
- function duration(text: string): number | null {
29
+ export function duration(text: string): number | null {
30
30
  let total = 0;
31
31
  const rest = text.replace(
32
32
  /(\d+(?:\.\d+)?|an?)\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/g,