@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,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,32 @@
1
+ /**
2
+ * The three public pairing routes: a body of at most 4 KiB, JSON, handed to
3
+ * the pairing code, and the answer never cached.
4
+ */
5
+
6
+ import { NextResponse, type NextRequest } from "next/server";
7
+ import { readCapped } from "../security/read-capped";
8
+ import { HELLO_MAX_BYTES } from "./hello";
9
+ import type { Reply } from "./pairing";
10
+
11
+ export function helloRoute(
12
+ handle: (body: unknown, request: NextRequest) => Reply | Promise<Reply>
13
+ ) {
14
+ return async (request: NextRequest) => {
15
+ const raw = await readCapped(request, HELLO_MAX_BYTES);
16
+ let reply: Reply;
17
+ if (raw === null) reply = { status: 413, body: { error: "too large" } };
18
+ else {
19
+ let body: unknown = null;
20
+ try {
21
+ body = JSON.parse(raw);
22
+ } catch {
23
+ // parsed as nothing: refused as a bad message
24
+ }
25
+ reply = await handle(body, request);
26
+ }
27
+ return NextResponse.json(reply.body, {
28
+ status: reply.status,
29
+ headers: { "Cache-Control": "no-store" },
30
+ });
31
+ };
32
+ }