@saadnvd1/agent-os 0.3.697 → 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.
@@ -4,38 +4,112 @@
4
4
  */
5
5
 
6
6
  import { getDb } from "../db";
7
+ import { PeerCallError, peerCall } from "../peer/client";
8
+ import type { PinState } from "../peer/links";
7
9
  import { getHost, isRemoteHost } from "./index";
10
+ import { readCappedBytes } from "./read-capped-bytes";
8
11
 
12
+ /** A machine linked through Connect: its pinned keys (lib/peer). */
13
+ export interface ConnectPeer {
14
+ machineId: string;
15
+ hostname: string;
16
+ signPub: string;
17
+ boxPub: string;
18
+ state: PinState;
19
+ suspendedReason: string | null;
20
+ }
21
+
22
+ /**
23
+ * `token` (the default): a device token over Tailscale or ssh-reached URLs.
24
+ * `connect`: signed, encrypted envelopes to the pinned keys; url and token
25
+ * are unused (token is always empty).
26
+ */
9
27
  export interface HostLink {
10
28
  hostId: string;
11
29
  hostName: string;
12
30
  url: string;
13
31
  token: string;
32
+ transport?: "token" | "connect";
33
+ connect?: ConnectPeer;
34
+ }
35
+
36
+ interface LinkRow {
37
+ host_id: string;
38
+ name: string;
39
+ kind: string;
40
+ url: string | null;
41
+ token: string | null;
42
+ machine_id: string | null;
43
+ hostname: string | null;
44
+ sign_pub: string | null;
45
+ box_pub: string | null;
46
+ state: string | null;
47
+ suspended_reason: string | null;
14
48
  }
15
49
 
16
50
  export function hostLink(hostId: string | null | undefined): HostLink | null {
17
51
  if (!isRemoteHost(hostId)) return null;
18
52
  const row = getDb()
19
53
  .prepare(
20
- `SELECT l.host_id, l.url, l.token, h.name FROM host_links l
21
- JOIN hosts h ON h.id = l.host_id WHERE l.host_id = ?`
54
+ `SELECT h.id AS host_id, h.name, h.kind, l.url, l.token, p.machine_id,
55
+ p.hostname, p.sign_pub, p.box_pub, p.state, p.suspended_reason
56
+ FROM hosts h
57
+ LEFT JOIN host_links l ON l.host_id = h.id
58
+ LEFT JOIN peer_links p ON p.host_id = h.id
59
+ WHERE h.id = ?`
22
60
  )
23
- .get(hostId) as
24
- { host_id: string; url: string; token: string; name: string } | undefined;
25
- return row
61
+ .get(hostId) as LinkRow | undefined;
62
+ if (!row) return null;
63
+ if (row.kind === "connect") {
64
+ if (!row.machine_id || !row.hostname || !row.sign_pub || !row.box_pub)
65
+ return null;
66
+ return {
67
+ hostId: row.host_id,
68
+ hostName: row.name,
69
+ url: `https://${row.hostname}`,
70
+ token: "",
71
+ transport: "connect",
72
+ connect: {
73
+ machineId: row.machine_id,
74
+ hostname: row.hostname,
75
+ signPub: row.sign_pub,
76
+ boxPub: row.box_pub,
77
+ state: row.state === "active" ? "active" : "suspended",
78
+ suspendedReason: row.suspended_reason,
79
+ },
80
+ };
81
+ }
82
+ return row.url && row.token
26
83
  ? {
27
84
  hostId: row.host_id,
28
85
  hostName: row.name,
29
86
  url: row.url,
30
87
  token: row.token,
88
+ transport: "token",
31
89
  }
32
90
  : null;
33
91
  }
34
92
 
93
+ export const isConnectLink = (link: HostLink) => link.transport === "connect";
94
+
95
+ /** For the calls that only work with a token link (sockets, raw fetches). */
96
+ export function requireTokenLink(link: HostLink): HostLink {
97
+ if (isConnectLink(link))
98
+ throw new Error(
99
+ `${link.hostName} is linked through Connect: this needs a Tailscale/ssh link for now`
100
+ );
101
+ return link;
102
+ }
103
+
35
104
  export function linkedHostIds(): Set<string> {
36
- const rows = getDb().prepare(`SELECT host_id FROM host_links`).all() as {
37
- host_id: string;
38
- }[];
105
+ const rows = getDb()
106
+ .prepare(
107
+ `SELECT host_id FROM host_links
108
+ WHERE host_id NOT IN (SELECT id FROM hosts WHERE kind = 'connect')
109
+ UNION SELECT p.host_id FROM peer_links p JOIN hosts h ON h.id = p.host_id
110
+ WHERE h.kind = 'connect'`
111
+ )
112
+ .all() as { host_id: string }[];
39
113
  return new Set(rows.map((r) => r.host_id));
40
114
  }
41
115
 
@@ -103,23 +177,7 @@ export async function readCapped(
103
177
  res: Response,
104
178
  max = MAX_RESPONSE_BYTES
105
179
  ): Promise<string> {
106
- if (Number(res.headers.get("content-length")) > max)
107
- throw new Error("its answer is too large");
108
- const reader = res.body?.getReader();
109
- if (!reader) return "";
110
- const chunks: Uint8Array[] = [];
111
- let total = 0;
112
- for (;;) {
113
- const { done, value } = await reader.read();
114
- if (done) break;
115
- total += value.byteLength;
116
- if (total > max) {
117
- await reader.cancel().catch(() => undefined);
118
- throw new Error("its answer is too large");
119
- }
120
- chunks.push(value);
121
- }
122
- return Buffer.concat(chunks).toString("utf8");
180
+ return (await readCappedBytes(res, max)).toString("utf8");
123
181
  }
124
182
 
125
183
  /** A JSON call to the other machine's AgentOS; its error text on failure. */
@@ -133,21 +191,34 @@ export async function hostApi<T>(
133
191
  `Can't reach AgentOS on ${link.hostName}: ${cleanRemoteText(err instanceof Error ? err.message : err)}`,
134
192
  false
135
193
  );
136
- let res: Response;
194
+ let status: number;
137
195
  let text: string;
138
196
  try {
139
- res = await fetch(`${link.url}${path}`, {
140
- method: opts.method ?? (opts.body === undefined ? "GET" : "POST"),
141
- headers: {
142
- Authorization: `Bearer ${link.token}`,
143
- ...(opts.body !== undefined && { "Content-Type": "application/json" }),
144
- },
145
- body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
146
- signal: AbortSignal.timeout(opts.timeout ?? 15000),
147
- redirect: "error",
148
- });
149
- text = await readCapped(res);
197
+ if (isConnectLink(link)) {
198
+ ({ status, text } = await peerCall(link, path, opts));
199
+ } else {
200
+ const res = await fetch(`${link.url}${path}`, {
201
+ method: opts.method ?? (opts.body === undefined ? "GET" : "POST"),
202
+ headers: {
203
+ Authorization: `Bearer ${link.token}`,
204
+ ...(opts.body !== undefined && {
205
+ "Content-Type": "application/json",
206
+ }),
207
+ },
208
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
209
+ signal: AbortSignal.timeout(opts.timeout ?? 15000),
210
+ redirect: "error",
211
+ });
212
+ status = res.status;
213
+ text = await readCapped(res);
214
+ }
150
215
  } catch (err) {
216
+ if (err instanceof PeerCallError && err.refused)
217
+ throw new HostApiError(
218
+ `${link.hostName}: ${cleanRemoteText(err.message)}`,
219
+ true,
220
+ err.status
221
+ );
151
222
  throw unknown(err);
152
223
  }
153
224
  let data: { error?: unknown } | null = null;
@@ -156,13 +227,13 @@ export async function hostApi<T>(
156
227
  } catch {
157
228
  data = null;
158
229
  }
159
- if (!res.ok) {
230
+ if (status < 200 || status > 299) {
160
231
  // Only AgentOS's own refusal (a 4xx with its error) means nothing happened.
161
- const refused = res.status >= 400 && res.status < 500 && !!data?.error;
232
+ const refused = status >= 400 && status < 500 && !!data?.error;
162
233
  throw new HostApiError(
163
- `${link.hostName}: ${cleanRemoteText(data?.error) || `HTTP ${res.status}`}`,
234
+ `${link.hostName}: ${cleanRemoteText(data?.error) || `HTTP ${status}`}`,
164
235
  refused,
165
- res.status
236
+ status
166
237
  );
167
238
  }
168
239
  if (!data) throw unknown(new Error("its answer wasn't JSON"));
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The only routes a linked machine may call here, by method and pattern.
3
+ * These are the calls the peer code makes today and nothing else. A path is
4
+ * taken only in its plain form: no encoding, no dot segments, no doubled or
5
+ * back slashes, so what is matched is exactly what the route sees. An id
6
+ * segment is [A-Za-z0-9_.-]{1,128}, and each route names its query keys.
7
+ */
8
+
9
+ type Method = "GET" | "POST" | "DELETE";
10
+
11
+ interface PeerRoute {
12
+ method: Method;
13
+ pattern: string;
14
+ query?: readonly string[];
15
+ }
16
+
17
+ const each = (
18
+ method: Method,
19
+ base: string,
20
+ tails: string[],
21
+ query?: string[]
22
+ ): PeerRoute[] =>
23
+ tails.map((t) => ({
24
+ method,
25
+ pattern: `${base}/${t}`,
26
+ ...(query && { query }),
27
+ }));
28
+
29
+ export const PEER_ROUTES: readonly PeerRoute[] = [
30
+ { method: "GET", pattern: "/api/tasks" },
31
+ { method: "POST", pattern: "/api/tasks" },
32
+ { method: "POST", pattern: "/api/tasks/import" },
33
+ { method: "GET", pattern: "/api/tasks/arrived", query: ["from"] },
34
+ { method: "GET", pattern: "/api/tasks/held-replies" },
35
+ { method: "POST", pattern: "/api/tasks/held-replies" },
36
+ ...each("POST", "/api/tasks/:id", [
37
+ "export",
38
+ "approve-plan",
39
+ "hold",
40
+ "message",
41
+ "stop",
42
+ "done",
43
+ "drop",
44
+ "resume",
45
+ "moved",
46
+ "merge",
47
+ ]),
48
+ ...each("GET", "/api/tasks/:id", ["plan", "chat-facts"]),
49
+ { method: "GET", pattern: "/api/tasks/:id/chat-tail", query: ["lines"] },
50
+
51
+ { method: "GET", pattern: "/api/sessions" },
52
+ { method: "GET", pattern: "/api/sessions/status" },
53
+ { method: "POST", pattern: "/api/sessions" },
54
+ { method: "DELETE", pattern: "/api/sessions/:id" },
55
+ ...each("POST", "/api/sessions/:id", ["done", "unarchive", "resurrect"]),
56
+ ...each("GET", "/api/sessions/:id", ["preview", "artifacts", "setup"]),
57
+
58
+ { method: "GET", pattern: "/api/projects" },
59
+ { method: "POST", pattern: "/api/projects/availability" },
60
+ { method: "GET", pattern: "/api/tmux/discover" },
61
+ { method: "GET", pattern: "/api/load" },
62
+
63
+ { method: "GET", pattern: "/api/worktree-sweep" },
64
+ { method: "POST", pattern: "/api/worktree-sweep" },
65
+ { method: "POST", pattern: "/api/worktree-sweep/run" },
66
+
67
+ { method: "POST", pattern: "/api/peer/unlink" },
68
+ { method: "GET", pattern: "/api/peer/ping" },
69
+
70
+ // The work registry's exchange (not built yet: 404 until it is).
71
+ { method: "POST", pattern: "/api/claims/exchange" },
72
+ ];
73
+
74
+ const PLAIN_PATH = /^\/api(\/[A-Za-z0-9_.-]{1,128}){1,6}$/;
75
+ const ID = /^[A-Za-z0-9_.-]{1,128}$/;
76
+ const MAX_QUERY = 4096;
77
+
78
+ export type RouteMatch =
79
+ | { ok: true; route: string; path: string; query: string }
80
+ | { ok: false; reason: "bad_path" | "bad_query" | "not_allowed" };
81
+
82
+ function segmentsMatch(pattern: string[], path: string[]): boolean {
83
+ return (
84
+ pattern.length === path.length &&
85
+ pattern.every((p, i) => (p === ":id" ? ID.test(path[i]) : p === path[i]))
86
+ );
87
+ }
88
+
89
+ function cleanQuery(
90
+ raw: string,
91
+ allowed: readonly string[] = []
92
+ ): string | null {
93
+ if (!raw) return "";
94
+ if (raw.length > MAX_QUERY || /[#\u0000-\u001f\u007f]/.test(raw)) return null;
95
+ const params = new URLSearchParams(raw);
96
+ const keys = [...params.keys()];
97
+ if (new Set(keys).size !== keys.length) return null;
98
+ for (const [k, v] of params) {
99
+ if (!allowed.includes(k) || v.length > 1024) return null;
100
+ if (/[\u0000-\u001f\u007f]/.test(v)) return null;
101
+ }
102
+ return params.toString();
103
+ }
104
+
105
+ /** The route a peer's call matches, with its path and query as dispatched. */
106
+ export function matchPeerRoute(
107
+ method: string,
108
+ path: string,
109
+ query = ""
110
+ ): RouteMatch {
111
+ if (typeof path !== "string" || !PLAIN_PATH.test(path))
112
+ return { ok: false, reason: "bad_path" };
113
+ const parts = path.split("/").slice(1);
114
+ if (parts.some((p) => p === "." || p === ".."))
115
+ return { ok: false, reason: "bad_path" };
116
+ const route = PEER_ROUTES.find(
117
+ (r) =>
118
+ r.method === method && segmentsMatch(r.pattern.split("/").slice(1), parts)
119
+ );
120
+ if (!route) return { ok: false, reason: "not_allowed" };
121
+ const clean = cleanQuery(query, route.query);
122
+ if (clean === null) return { ok: false, reason: "bad_query" };
123
+ return { ok: true, route: route.pattern, path, query: clean };
124
+ }
125
+
126
+ /** For the gate: a request target (path and query) a peer may reach. */
127
+ export function peerTargetAllowed(
128
+ method: string | undefined,
129
+ url: string | undefined
130
+ ) {
131
+ const target = url ?? "";
132
+ const q = target.indexOf("?");
133
+ const path = q < 0 ? target : target.slice(0, q);
134
+ const query = q < 0 ? "" : target.slice(q + 1);
135
+ return matchPeerRoute(method ?? "", path, query).ok;
136
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Peer calls, in and out. Successful reads (the mirror polls every few
3
+ * seconds) fold into one row per route per hour. Failures are capped per
4
+ * minute so a flood can't fill the disk, in two budgets: envelopes refused
5
+ * here before their sender was proven (anyone can send those), and
6
+ * everything else (this machine's own failed calls, a linked machine's
7
+ * denied routes), so a forger's flood never hides the second kind. Rows older than 30 days go, checked at most hourly.
8
+ * Bookkeeping: a failure here never fails the call.
9
+ */
10
+
11
+ import { getDb } from "../db";
12
+
13
+ export type Outcome =
14
+ | "ok"
15
+ | "denied_route"
16
+ | "bad_envelope"
17
+ | "too_large"
18
+ | "too_slow"
19
+ | "busy"
20
+ | "bad_sig"
21
+ | "stale"
22
+ | "replay"
23
+ | "unknown_peer"
24
+ | "suspended";
25
+
26
+ export interface AuditEntry {
27
+ hostId: string | null;
28
+ machineId?: string | null;
29
+ direction: "in" | "out";
30
+ method: string;
31
+ /** The route's template (/api/tasks/:id/merge), never the raw path. */
32
+ route: string;
33
+ status: number;
34
+ ms: number;
35
+ bytes: number;
36
+ outcome: Outcome;
37
+ }
38
+
39
+ const HOUR = 3_600_000;
40
+ const RETAIN_MS = 30 * 24 * HOUR;
41
+ const FAILURES_PER_MINUTE = 30;
42
+
43
+ const state = { prunedAt: 0, minute: 0, forged: 0, other: 0 };
44
+
45
+ /** For tests: forget the prune time and the failure budget. */
46
+ export const resetAuditLimits = () => {
47
+ state.prunedAt = 0;
48
+ state.minute = 0;
49
+ state.forged = 0;
50
+ state.other = 0;
51
+ };
52
+
53
+ // Refusals of an inbound envelope that anyone could have sent: a stranger's,
54
+ // a forgery, a malformed or oversized one, or a captured one sent again.
55
+ const UNPROVEN: ReadonlySet<Outcome> = new Set([
56
+ "bad_envelope",
57
+ "too_large",
58
+ "too_slow",
59
+ "busy",
60
+ "bad_sig",
61
+ "unknown_peer",
62
+ "suspended",
63
+ "stale",
64
+ "replay",
65
+ ]);
66
+
67
+ function underFailureCap(e: AuditEntry, now: number): boolean {
68
+ const minute = Math.floor(now / 60_000);
69
+ if (minute !== state.minute) {
70
+ state.minute = minute;
71
+ state.forged = 0;
72
+ state.other = 0;
73
+ }
74
+ const key =
75
+ e.direction === "in" && UNPROVEN.has(e.outcome) ? "forged" : "other";
76
+ return ++state[key] <= FAILURES_PER_MINUTE;
77
+ }
78
+
79
+ export function recordPeerCall(e: AuditEntry, now = Date.now()): void {
80
+ try {
81
+ if (e.outcome !== "ok" && !underFailureCap(e, now)) return;
82
+ const db = getDb();
83
+ if (now - state.prunedAt > HOUR) {
84
+ state.prunedAt = now;
85
+ db.prepare(`DELETE FROM peer_audit WHERE at < ?`).run(now - RETAIN_MS);
86
+ }
87
+ const machineId = e.machineId?.slice(0, 64) ?? null;
88
+ const fold = e.method === "GET" && e.outcome === "ok" && e.status < 400;
89
+ const bucket = fold ? String(Math.floor(now / HOUR)) : null;
90
+ if (bucket) {
91
+ const changed = db
92
+ .prepare(
93
+ `UPDATE peer_audit SET count = count + 1, at = ?, status = ?, ms = ?,
94
+ bytes = bytes + ?
95
+ WHERE host_id IS ? AND direction = ? AND route = ? AND bucket = ?`
96
+ )
97
+ .run(
98
+ now,
99
+ e.status,
100
+ e.ms,
101
+ e.bytes,
102
+ e.hostId,
103
+ e.direction,
104
+ e.route,
105
+ bucket
106
+ );
107
+ if (changed.changes > 0) return;
108
+ }
109
+ db.prepare(
110
+ `INSERT INTO peer_audit (at, host_id, machine_id, direction, method, route,
111
+ status, ms, bytes, outcome, bucket)
112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
113
+ ).run(
114
+ now,
115
+ e.hostId,
116
+ machineId,
117
+ e.direction,
118
+ e.method.slice(0, 10),
119
+ e.route.slice(0, 200),
120
+ e.status,
121
+ Math.round(e.ms),
122
+ e.bytes,
123
+ e.outcome,
124
+ bucket
125
+ );
126
+ } catch (err) {
127
+ console.error("peer audit:", err);
128
+ }
129
+ }
130
+
131
+ export interface AuditRow extends AuditEntry {
132
+ at: number;
133
+ count: number;
134
+ }
135
+
136
+ export function peerAudit(hostId: string, limit = 200): AuditRow[] {
137
+ return getDb()
138
+ .prepare(
139
+ `SELECT at, host_id AS hostId, machine_id AS machineId, direction, method,
140
+ route, status, ms, bytes, outcome, count
141
+ FROM peer_audit WHERE host_id = ? ORDER BY at DESC, id DESC LIMIT ?`
142
+ )
143
+ .all(hostId, limit) as AuditRow[];
144
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * hostApi's transport for a machine linked through Connect: each call is a
3
+ * fresh envelope (envelope.ts) to POST https://<its m- name>/api/peer/rpc,
4
+ * and the answer only counts if it is signed by the pinned key and bound to
5
+ * this request's nonce. No token is ever sent or stored.
6
+ */
7
+
8
+ import { matchPeerRoute } from "./allowlist";
9
+ import { recordPeerCall, type AuditEntry, type Outcome } from "./audit";
10
+ import {
11
+ MAX_REQUEST_BODY,
12
+ MAX_RESPONSE_ENVELOPE,
13
+ RPC_PATH,
14
+ openResponse,
15
+ sealRequest,
16
+ } from "./envelope";
17
+ import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
18
+ import { notePinResult } from "./links";
19
+ import type { HostLink } from "../hosts/remote-api";
20
+ import { readCappedBytes } from "../hosts/read-capped-bytes";
21
+
22
+ /**
23
+ * A failed peer call. `refused`: the other machine (or this one, before
24
+ * sending) said no, so nothing happened there.
25
+ */
26
+ export class PeerCallError extends Error {
27
+ constructor(
28
+ message: string,
29
+ readonly refused: boolean,
30
+ readonly status?: number
31
+ ) {
32
+ super(message);
33
+ }
34
+ }
35
+
36
+ const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
37
+
38
+ /**
39
+ * Only a Connect machine's own name, so a lying account list can't aim this
40
+ * machine at an internal address. AGENTOS_CONNECT_PEER_DOMAIN is for a
41
+ * staging service.
42
+ */
43
+ export function peerHostAllowed(hostname: string, env = process.env): boolean {
44
+ const configured = env.AGENTOS_CONNECT_PEER_DOMAIN ?? "";
45
+ const domain = DOMAIN.test(configured) ? configured : "runagentos.com";
46
+ const escaped = domain.replace(/\./g, "\\.");
47
+ return new RegExp(`^m-[0-9a-f]{16}\\.${escaped}$`).test(hostname);
48
+ }
49
+
50
+ // The fixed refusals rpc-server.ts sends before anything is decrypted.
51
+ const REFUSALS: Record<string, Outcome> = {
52
+ "bad signature": "bad_sig",
53
+ "bad envelope": "bad_sig",
54
+ stale: "stale",
55
+ replay: "replay",
56
+ busy: "replay",
57
+ "too large": "bad_sig",
58
+ };
59
+
60
+ const HINTS: Record<string, string> = {
61
+ "bad signature":
62
+ "it doesn't know this machine, the link is suspended there, or the keys don't match",
63
+ stale: "the clocks on the two machines differ by more than 2 minutes",
64
+ };
65
+
66
+ export interface ClientDeps {
67
+ identity: () => Identity | null;
68
+ audit: (e: AuditEntry) => void;
69
+ noteResult: (hostId: string, error: string | null) => void;
70
+ }
71
+
72
+ const realDeps: ClientDeps = {
73
+ identity: () => currentIdentity(),
74
+ audit: recordPeerCall,
75
+ noteResult: notePinResult,
76
+ };
77
+
78
+ export async function peerCall(
79
+ link: HostLink,
80
+ target: string,
81
+ opts: { method?: string; body?: unknown; timeout?: number },
82
+ deps: ClientDeps = realDeps
83
+ ): Promise<{ status: number; text: string }> {
84
+ const peer = link.connect;
85
+ const refuse = (msg: string): never => {
86
+ throw new PeerCallError(msg, true);
87
+ };
88
+ if (!peer) return refuse("isn't linked through Connect");
89
+ if (peer.state !== "active")
90
+ return refuse(
91
+ `the link is suspended (${peer.suspendedReason ?? "no reason given"})`
92
+ );
93
+ if (!peerHostAllowed(peer.hostname))
94
+ return refuse("its address isn't a Connect machine's");
95
+ const method = (
96
+ opts.method ?? (opts.body === undefined ? "GET" : "POST")
97
+ ).toUpperCase();
98
+ const q = target.indexOf("?");
99
+ const match = matchPeerRoute(
100
+ method,
101
+ q < 0 ? target : target.slice(0, q),
102
+ q < 0 ? "" : target.slice(q + 1)
103
+ );
104
+ if (!match.ok) return refuse("that call isn't open to linked machines");
105
+ const identity = deps.identity();
106
+ if (!identity)
107
+ return refuse("this machine isn't connected to AgentOS Connect");
108
+ const body =
109
+ opts.body === undefined
110
+ ? Buffer.alloc(0)
111
+ : Buffer.from(JSON.stringify(opts.body));
112
+ if (body.length > MAX_REQUEST_BODY) return refuse("the request is too large");
113
+
114
+ const started = Date.now();
115
+ const audit = (outcome: Outcome, status: number, bytes: number) =>
116
+ deps.audit({
117
+ hostId: link.hostId,
118
+ machineId: peer.machineId,
119
+ direction: "out",
120
+ method,
121
+ route: match.route,
122
+ status,
123
+ ms: Date.now() - started,
124
+ bytes,
125
+ outcome,
126
+ });
127
+ const sealed = sealRequest({
128
+ from: identity.machineId,
129
+ to: peer.machineId,
130
+ signKey: identity.signKey,
131
+ peerBox: boxPublic(peer.boxPub),
132
+ request: {
133
+ method,
134
+ path: match.path,
135
+ query: match.query,
136
+ contentType: body.length ? "application/json" : "",
137
+ body,
138
+ },
139
+ });
140
+ let res: Response;
141
+ let raw: Buffer;
142
+ try {
143
+ res = await fetch(`https://${peer.hostname}${RPC_PATH}`, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/octet-stream" },
146
+ body: new Uint8Array(sealed.envelope),
147
+ signal: AbortSignal.timeout(opts.timeout ?? 15000),
148
+ redirect: "error",
149
+ });
150
+ raw = await readCappedBytes(res, MAX_RESPONSE_ENVELOPE);
151
+ } catch (err) {
152
+ const message = err instanceof Error ? err.message : String(err);
153
+ deps.noteResult(link.hostId, message);
154
+ throw new PeerCallError(message, false);
155
+ }
156
+ if (res.status !== 200) {
157
+ const text = raw.subarray(0, 64).toString("utf8").trim();
158
+ const outcome = REFUSALS[text];
159
+ if (outcome && res.status >= 400 && res.status < 500) {
160
+ audit(outcome, res.status, sealed.envelope.length);
161
+ const message = HINTS[text] ? `${text}: ${HINTS[text]}` : text;
162
+ deps.noteResult(link.hostId, message);
163
+ // A refusal is unsigned: anyone on the path could forge one. A read
164
+ // changes nothing either way, but a write may have happened.
165
+ throw new PeerCallError(message, method === "GET", res.status);
166
+ }
167
+ deps.noteResult(link.hostId, `HTTP ${res.status}`);
168
+ throw new PeerCallError(`HTTP ${res.status}`, false, res.status);
169
+ }
170
+ let answer;
171
+ try {
172
+ answer = openResponse({
173
+ envelope: raw,
174
+ nonce: sealed.nonce,
175
+ resKey: sealed.resKey,
176
+ peerSign: signPublic(peer.signPub),
177
+ });
178
+ } catch (err) {
179
+ // It may have run there; the answer just can't be trusted.
180
+ const message = err instanceof Error ? err.message : String(err);
181
+ audit("bad_sig", 200, sealed.envelope.length + raw.length);
182
+ deps.noteResult(link.hostId, message);
183
+ throw new PeerCallError(message, false);
184
+ }
185
+ audit("ok", answer.status, body.length + answer.body.length);
186
+ deps.noteResult(link.hostId, null);
187
+ return { status: answer.status, text: answer.body.toString("utf8") };
188
+ }