@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,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
+ }
@@ -0,0 +1,424 @@
1
+ /**
2
+ * The signed, encrypted envelope linked machines call each other with
3
+ * (Node's crypto only).
4
+ *
5
+ * Request body: u32 header length ‖ header JSON ‖ hsig ‖ ciphertext ‖ sig.
6
+ * header {v:1, from, to, ts, nonce, epk}, epk a fresh X25519 key per call
7
+ * hsig Ed25519_A("agentos-peer-hdr-v1" ‖ header), so the receiver can
8
+ * prove the sender before it holds any of the body
9
+ * keys HKDF-SHA256(X25519(epk, B_box), salt=nonce,
10
+ * info="agentos-peer-v1|from|to") → k_req ‖ k_res
11
+ * ct ChaCha20-Poly1305(k_req, AAD=header) over the inner request
12
+ * sig Ed25519_A("agentos-peer-req-v1" ‖ header ‖ SHA256(ct))
13
+ *
14
+ * Response body: iv ‖ ct ‖ sig, where iv is 12 fresh random bytes, ct =
15
+ * ChaCha20-Poly1305(k_res, iv) over the inner response and sig =
16
+ * Ed25519_B("agentos-peer-res-v1" ‖ request nonce ‖ iv ‖ SHA256(ct)), so it
17
+ * only opens for the request it answers.
18
+ *
19
+ * k_req seals exactly one message, so the request uses a fixed all-zero IV.
20
+ * The response doesn't rely on that: should a request ever be answered twice,
21
+ * a fresh IV keeps the two answers from sharing a keystream.
22
+ */
23
+
24
+ import crypto, { type KeyObject } from "crypto";
25
+ import { MACHINE_ID } from "../connect/control-api";
26
+ import { rawPublic } from "./keys";
27
+ import { type ReplayGuard } from "./replay";
28
+
29
+ export const RPC_PATH = "/api/peer/rpc";
30
+ export const MAX_REQUEST_BODY = 100 * 1024 * 1024;
31
+ export const MAX_RESPONSE_BODY = 80 * 1024 * 1024;
32
+ const OVERHEAD = 64 * 1024;
33
+ export const MAX_REQUEST_ENVELOPE = MAX_REQUEST_BODY + OVERHEAD;
34
+ export const MAX_RESPONSE_ENVELOPE = MAX_RESPONSE_BODY + OVERHEAD;
35
+ export const SKEW_MS = 120_000;
36
+
37
+ export const MAX_HEADER = 1024;
38
+ const MAX_META = 16 * 1024;
39
+ const SIG = 64;
40
+ const TAG = 16;
41
+ const ZERO_IV = Buffer.alloc(12);
42
+ const IV_LEN = 12;
43
+ export const HEADER_SIG = SIG;
44
+ const HDR_DOMAIN = Buffer.from("agentos-peer-hdr-v1");
45
+ const REQ_DOMAIN = Buffer.from("agentos-peer-req-v1");
46
+ const RES_DOMAIN = Buffer.from("agentos-peer-res-v1");
47
+ const B64_16 = /^[A-Za-z0-9_-]{22}$/;
48
+ const B64_32 = /^[A-Za-z0-9_-]{43}$/;
49
+
50
+ export interface PeerRequest {
51
+ method: string;
52
+ path: string;
53
+ /** URL query without the "?"; checked against the route's keys. */
54
+ query: string;
55
+ contentType: string;
56
+ body: Buffer;
57
+ }
58
+
59
+ export interface PeerResponse {
60
+ status: number;
61
+ contentType: string;
62
+ body: Buffer;
63
+ }
64
+
65
+ export interface RequestHeader {
66
+ v: 1;
67
+ from: string;
68
+ to: string;
69
+ ts: number;
70
+ nonce: string;
71
+ epk: string;
72
+ }
73
+
74
+ /** Why an envelope was refused before anything inside it was trusted. */
75
+ export type Refusal =
76
+ | "bad_envelope"
77
+ | "wrong_machine"
78
+ | "unknown_peer"
79
+ | "suspended"
80
+ | "bad_sig"
81
+ | "stale"
82
+ | "replay"
83
+ | "busy";
84
+
85
+ const sha256 = (b: Buffer) => crypto.createHash("sha256").update(b).digest();
86
+
87
+ function frame(meta: object, body: Buffer): Buffer {
88
+ const m = Buffer.from(JSON.stringify(meta));
89
+ const len = Buffer.alloc(4);
90
+ len.writeUInt32BE(m.length);
91
+ return Buffer.concat([len, m, body]);
92
+ }
93
+
94
+ function unframe(buf: Buffer, max: number): { meta: unknown; rest: Buffer } {
95
+ if (buf.length < 4) throw new Error("short");
96
+ const len = buf.readUInt32BE(0);
97
+ if (len > max || 4 + len > buf.length) throw new Error("bad length");
98
+ return {
99
+ meta: JSON.parse(buf.subarray(4, 4 + len).toString("utf8")),
100
+ rest: buf.subarray(4 + len),
101
+ };
102
+ }
103
+
104
+ function keysFor(
105
+ shared: Buffer,
106
+ nonce: Buffer,
107
+ from: string,
108
+ to: string
109
+ ): { req: Buffer; res: Buffer } {
110
+ const okm = Buffer.from(
111
+ crypto.hkdfSync(
112
+ "sha256",
113
+ shared,
114
+ nonce,
115
+ `agentos-peer-v1|${from}|${to}`,
116
+ 64
117
+ )
118
+ );
119
+ return { req: okm.subarray(0, 32), res: okm.subarray(32) };
120
+ }
121
+
122
+ function seal(
123
+ key: Buffer,
124
+ plain: Buffer,
125
+ o: { aad?: Buffer; iv?: Buffer } = {}
126
+ ): Buffer {
127
+ const { aad, iv = ZERO_IV } = o;
128
+ const c = crypto.createCipheriv("chacha20-poly1305", key, iv, {
129
+ authTagLength: TAG,
130
+ });
131
+ if (aad) c.setAAD(aad, { plaintextLength: plain.length });
132
+ return Buffer.concat([c.update(plain), c.final(), c.getAuthTag()]);
133
+ }
134
+
135
+ function unseal(
136
+ key: Buffer,
137
+ ct: Buffer,
138
+ o: { aad?: Buffer; iv?: Buffer } = {}
139
+ ): Buffer {
140
+ const { aad, iv = ZERO_IV } = o;
141
+ if (ct.length < TAG) throw new Error("short");
142
+ const d = crypto.createDecipheriv("chacha20-poly1305", key, iv, {
143
+ authTagLength: TAG,
144
+ });
145
+ d.setAuthTag(ct.subarray(ct.length - TAG));
146
+ if (aad) d.setAAD(aad, { plaintextLength: ct.length - TAG });
147
+ return Buffer.concat([d.update(ct.subarray(0, ct.length - TAG)), d.final()]);
148
+ }
149
+
150
+ export interface Sealed {
151
+ envelope: Buffer;
152
+ nonce: Buffer;
153
+ /** Opens the answer to this request, and nothing else. */
154
+ resKey: Buffer;
155
+ }
156
+
157
+ export function sealRequest(o: {
158
+ from: string;
159
+ to: string;
160
+ signKey: KeyObject;
161
+ peerBox: KeyObject;
162
+ request: PeerRequest;
163
+ now?: number;
164
+ }): Sealed {
165
+ const nonce = crypto.randomBytes(16);
166
+ const eph = crypto.generateKeyPairSync("x25519");
167
+ const header: RequestHeader = {
168
+ v: 1,
169
+ from: o.from,
170
+ to: o.to,
171
+ ts: o.now ?? Date.now(),
172
+ nonce: nonce.toString("base64url"),
173
+ epk: rawPublic(eph.publicKey),
174
+ };
175
+ const headerBytes = Buffer.from(JSON.stringify(header));
176
+ const shared = crypto.diffieHellman({
177
+ privateKey: eph.privateKey,
178
+ publicKey: o.peerBox,
179
+ });
180
+ const keys = keysFor(shared, nonce, o.from, o.to);
181
+ const { body, ...meta } = o.request;
182
+ const ct = seal(keys.req, frame(meta, body), { aad: headerBytes });
183
+ const sig = crypto.sign(
184
+ null,
185
+ Buffer.concat([REQ_DOMAIN, headerBytes, sha256(ct)]),
186
+ o.signKey
187
+ );
188
+ const hsig = crypto.sign(
189
+ null,
190
+ Buffer.concat([HDR_DOMAIN, headerBytes]),
191
+ o.signKey
192
+ );
193
+ const len = Buffer.alloc(4);
194
+ len.writeUInt32BE(headerBytes.length);
195
+ return {
196
+ envelope: Buffer.concat([len, headerBytes, hsig, ct, sig]),
197
+ nonce,
198
+ resKey: keys.res,
199
+ };
200
+ }
201
+
202
+ function parseHeader(raw: unknown): RequestHeader | null {
203
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
204
+ const h = raw as Record<string, unknown>;
205
+ const keys = Object.keys(h).sort().join(",");
206
+ if (keys !== "epk,from,nonce,to,ts,v") return null;
207
+ if (h.v !== 1) return null;
208
+ if (typeof h.from !== "string" || !MACHINE_ID.test(h.from)) return null;
209
+ if (typeof h.to !== "string" || !MACHINE_ID.test(h.to)) return null;
210
+ if (typeof h.ts !== "number" || !Number.isSafeInteger(h.ts)) return null;
211
+ if (typeof h.nonce !== "string" || !B64_16.test(h.nonce)) return null;
212
+ if (typeof h.epk !== "string" || !B64_32.test(h.epk)) return null;
213
+ return h as unknown as RequestHeader;
214
+ }
215
+
216
+ /** The header alone, as soon as it has arrived (to refuse early). */
217
+ export function parseHeaderBytes(bytes: Buffer): RequestHeader | null {
218
+ try {
219
+ return parseHeader(JSON.parse(bytes.toString("utf8")));
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /** Whether the header alone is signed by `signKey`. */
226
+ export function verifyHeader(
227
+ headerBytes: Buffer,
228
+ hsig: Buffer,
229
+ signKey: KeyObject
230
+ ): boolean {
231
+ try {
232
+ return crypto.verify(
233
+ null,
234
+ Buffer.concat([HDR_DOMAIN, headerBytes]),
235
+ signKey,
236
+ hsig
237
+ );
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ export interface ParsedRequest {
244
+ header: RequestHeader;
245
+ headerBytes: Buffer;
246
+ hsig: Buffer;
247
+ ct: Buffer;
248
+ sig: Buffer;
249
+ }
250
+
251
+ /** The envelope's parts, or null when it isn't shaped like one. */
252
+ export function parseRequest(envelope: Buffer): ParsedRequest | null {
253
+ try {
254
+ if (envelope.length > MAX_REQUEST_ENVELOPE) return null;
255
+ const { meta, rest } = unframe(envelope, MAX_HEADER);
256
+ const header = parseHeader(meta);
257
+ if (!header || rest.length < SIG + TAG + SIG) return null;
258
+ const headerLen = envelope.readUInt32BE(0);
259
+ return {
260
+ header,
261
+ headerBytes: envelope.subarray(4, 4 + headerLen),
262
+ hsig: rest.subarray(0, SIG),
263
+ ct: rest.subarray(SIG, rest.length - SIG),
264
+ sig: rest.subarray(rest.length - SIG),
265
+ };
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ export interface OpenDeps {
272
+ /** This machine's Connect id: an envelope for any other is refused. */
273
+ self: string;
274
+ boxKey: KeyObject;
275
+ /** The pinned signing key for a peer, or why there isn't one. */
276
+ signKeyFor: (machineId: string) => KeyObject | "unknown_peer" | "suspended";
277
+ replay: ReplayGuard;
278
+ now?: number;
279
+ }
280
+
281
+ export type Opened =
282
+ | {
283
+ ok: true;
284
+ header: RequestHeader;
285
+ request: PeerRequest;
286
+ nonce: Buffer;
287
+ resKey: Buffer;
288
+ }
289
+ | { ok: false; refusal: Refusal; header?: RequestHeader };
290
+
291
+ /**
292
+ * Checks, in order: shape, addressed to this machine, a pinned peer, its
293
+ * signature, the clock, a fresh nonce; then decrypts. A nonce is remembered
294
+ * only once the signature holds, so nobody else can burn a peer's nonces.
295
+ */
296
+ export function openRequest(envelope: Buffer, deps: OpenDeps): Opened {
297
+ const parsed = parseRequest(envelope);
298
+ if (!parsed) return { ok: false, refusal: "bad_envelope" };
299
+ const { header, headerBytes, hsig, ct, sig } = parsed;
300
+ const fail = (refusal: Refusal): Opened => ({ ok: false, refusal, header });
301
+ if (header.to !== deps.self) return fail("wrong_machine");
302
+ const signKey = deps.signKeyFor(header.from);
303
+ if (typeof signKey === "string") return fail(signKey);
304
+ let verified = verifyHeader(headerBytes, hsig, signKey);
305
+ try {
306
+ verified &&= crypto.verify(
307
+ null,
308
+ Buffer.concat([REQ_DOMAIN, headerBytes, sha256(ct)]),
309
+ signKey,
310
+ sig
311
+ );
312
+ } catch {
313
+ verified = false;
314
+ }
315
+ if (!verified) return fail("bad_sig");
316
+ const now = deps.now ?? Date.now();
317
+ const fresh = deps.replay.check(header.from, header.nonce, header.ts, now);
318
+ if (fresh !== "ok") return fail(fresh);
319
+ try {
320
+ const nonce = Buffer.from(header.nonce, "base64url");
321
+ const epk = crypto.createPublicKey({
322
+ key: { kty: "OKP", crv: "X25519", x: header.epk },
323
+ format: "jwk",
324
+ });
325
+ const shared = crypto.diffieHellman({
326
+ privateKey: deps.boxKey,
327
+ publicKey: epk,
328
+ });
329
+ const keys = keysFor(shared, nonce, header.from, header.to);
330
+ const { meta, rest } = unframe(
331
+ unseal(keys.req, ct, { aad: headerBytes }),
332
+ MAX_META
333
+ );
334
+ const request = parseInner(meta, rest);
335
+ if (!request) return fail("bad_envelope");
336
+ return { ok: true, header, request, nonce, resKey: keys.res };
337
+ } catch {
338
+ return fail("bad_envelope");
339
+ }
340
+ }
341
+
342
+ function parseInner(meta: unknown, body: Buffer): PeerRequest | null {
343
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
344
+ const m = meta as Record<string, unknown>;
345
+ if (
346
+ typeof m.method !== "string" ||
347
+ typeof m.path !== "string" ||
348
+ typeof m.query !== "string" ||
349
+ typeof m.contentType !== "string" ||
350
+ body.length > MAX_REQUEST_BODY
351
+ )
352
+ return null;
353
+ return {
354
+ method: m.method,
355
+ path: m.path,
356
+ query: m.query,
357
+ contentType: m.contentType,
358
+ body,
359
+ };
360
+ }
361
+
362
+ export function sealResponse(o: {
363
+ resKey: Buffer;
364
+ nonce: Buffer;
365
+ signKey: KeyObject;
366
+ response: PeerResponse;
367
+ }): Buffer {
368
+ const { body, ...meta } = o.response;
369
+ const iv = crypto.randomBytes(IV_LEN);
370
+ const ct = seal(o.resKey, frame(meta, body), { iv });
371
+ const sig = crypto.sign(
372
+ null,
373
+ Buffer.concat([RES_DOMAIN, o.nonce, iv, sha256(ct)]),
374
+ o.signKey
375
+ );
376
+ return Buffer.concat([iv, ct, sig]);
377
+ }
378
+
379
+ /** The answer to the request sealed with this nonce and key, from that peer. */
380
+ export function openResponse(o: {
381
+ envelope: Buffer;
382
+ nonce: Buffer;
383
+ resKey: Buffer;
384
+ peerSign: KeyObject;
385
+ }): PeerResponse {
386
+ const { envelope } = o;
387
+ if (
388
+ envelope.length < IV_LEN + TAG + SIG ||
389
+ envelope.length > MAX_RESPONSE_ENVELOPE
390
+ )
391
+ throw new Error("its answer isn't an envelope");
392
+ const iv = envelope.subarray(0, IV_LEN);
393
+ const ct = envelope.subarray(IV_LEN, envelope.length - SIG);
394
+ const sig = envelope.subarray(envelope.length - SIG);
395
+ let ok = false;
396
+ try {
397
+ ok = crypto.verify(
398
+ null,
399
+ Buffer.concat([RES_DOMAIN, o.nonce, iv, sha256(ct)]),
400
+ o.peerSign,
401
+ sig
402
+ );
403
+ } catch {
404
+ ok = false;
405
+ }
406
+ if (!ok) throw new Error("its answer isn't signed by the linked machine");
407
+ let meta: unknown;
408
+ let body: Buffer;
409
+ try {
410
+ ({ meta, rest: body } = unframe(unseal(o.resKey, ct, { iv }), MAX_META));
411
+ } catch {
412
+ throw new Error("its answer doesn't decrypt");
413
+ }
414
+ const m = (meta ?? {}) as Record<string, unknown>;
415
+ if (
416
+ typeof m.status !== "number" ||
417
+ !Number.isInteger(m.status) ||
418
+ m.status < 100 ||
419
+ m.status > 599 ||
420
+ typeof m.contentType !== "string"
421
+ )
422
+ throw new Error("its answer is malformed");
423
+ return { status: m.status, contentType: m.contentType, body };
424
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The per-boot secret the rpc server's loopback hop carries, so the gate
3
+ * knows a request is a verified peer call and not this machine itself.
4
+ * Held on globalThis: server.ts and the gate must share one value.
5
+ */
6
+
7
+ import crypto from "crypto";
8
+
9
+ const g = globalThis as unknown as { __agentosPeerSecret?: string };
10
+
11
+ export function peerSecret(): string {
12
+ g.__agentosPeerSecret ??= crypto.randomBytes(32).toString("hex");
13
+ return g.__agentosPeerSecret;
14
+ }
15
+
16
+ /** Constant-time: how much of the secret matched never shows in timing. */
17
+ export function secretMatches(given: unknown, secret: string): boolean {
18
+ if (typeof given !== "string" || !secret) return false;
19
+ const a = Buffer.from(given);
20
+ const b = Buffer.from(secret);
21
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
22
+ }