@saadnvd1/agent-os 0.3.698 → 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 (65) 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/peers/account/route.ts +9 -0
  7. package/app/api/peers/gate.test.ts +111 -0
  8. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  9. package/app/api/peers/link/[id]/route.ts +20 -0
  10. package/app/api/peers/link/route.ts +23 -0
  11. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  12. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  13. package/app/api/peers/pending/route.ts +7 -0
  14. package/app/api/sessions/route.ts +18 -1
  15. package/app/page.tsx +2 -0
  16. package/components/Hosts/AccountMachines.tsx +180 -0
  17. package/components/Hosts/HostRow.tsx +44 -35
  18. package/components/Hosts/HostsDialog.tsx +49 -37
  19. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  20. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  21. package/components/Hosts/sheet.ts +3 -0
  22. package/components/SessionList/RowSubtitle.tsx +11 -1
  23. package/components/Welcome/ConnectBlock.tsx +1 -1
  24. package/data/peers/index.ts +2 -0
  25. package/data/peers/keys.ts +6 -0
  26. package/data/peers/queries.ts +141 -0
  27. package/lib/agents/brief.ts +3 -1
  28. package/lib/bus/format.ts +25 -4
  29. package/lib/bus/index.ts +113 -106
  30. package/lib/bus/resolve.ts +16 -2
  31. package/lib/bus/targets.ts +122 -0
  32. package/lib/bus/waits/kick.ts +12 -0
  33. package/lib/bus/waits/lines.ts +69 -0
  34. package/lib/bus/waits/register.ts +83 -0
  35. package/lib/bus/waits/store.ts +196 -0
  36. package/lib/bus/waits/tick.ts +192 -0
  37. package/lib/connect/control-api.ts +108 -2
  38. package/lib/connect/heartbeat.ts +11 -3
  39. package/lib/connect/serve.ts +2 -0
  40. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  41. package/lib/db/types.ts +4 -0
  42. package/lib/done/archive.ts +2 -0
  43. package/lib/orchestrator/describe.ts +13 -0
  44. package/lib/orchestrator/facts.ts +17 -0
  45. package/lib/peer/account.ts +198 -0
  46. package/lib/peer/cli.ts +24 -0
  47. package/lib/peer/code.ts +3 -0
  48. package/lib/peer/hello-route.ts +32 -0
  49. package/lib/peer/hello.ts +346 -0
  50. package/lib/peer/link-cli.ts +233 -0
  51. package/lib/peer/machines.ts +72 -0
  52. package/lib/peer/notify.ts +32 -0
  53. package/lib/peer/pairing-out.ts +382 -0
  54. package/lib/peer/pairing.ts +403 -0
  55. package/lib/peer/pin.ts +55 -0
  56. package/lib/peer/rpc-intake.ts +37 -8
  57. package/lib/peer/rpc-server.ts +19 -1
  58. package/lib/schedules/when.ts +1 -1
  59. package/lib/security/auth.ts +10 -2
  60. package/lib/security/gate.ts +3 -0
  61. package/lib/sessions/end.ts +3 -0
  62. package/package.json +1 -1
  63. package/scripts/agent-os +2 -0
  64. package/scripts/lib/commands.sh +2 -0
  65. package/server.ts +3 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Pairing, on the machine being asked (B). A hello is taken only from a key
3
+ * the Connect account lists for its sender, and only while the plan
4
+ * includes linking; it then waits, in memory, for the sender to reveal the
5
+ * nonce it committed to, and then for a person here to type the code the
6
+ * sender shows. Nothing is trusted until that code matches: B pins A then
7
+ * and shows a second code, which the person types on A before A pins B.
8
+ * A machine linked here already keeps its keys: one that comes back with
9
+ * other keys is refused until a person unlinks it.
10
+ *
11
+ * Limits: hellos 5 per sender and 20 in all per 10 minutes, at most 3
12
+ * requests open and one per sender (a newer one replaces it), each for 5
13
+ * minutes, and 3 wrong codes void a request.
14
+ * A server restart forgets every open request (the sender sees "expired").
15
+ */
16
+
17
+ import { currentIdentity, loadBoxKey, type Identity } from "./keys";
18
+ import { linkingAllowed, listedPeer, type AccountPeer } from "./account";
19
+ import {
20
+ commitTo,
21
+ deriveCode,
22
+ parseHello,
23
+ parseReveal,
24
+ parseStatusAsk,
25
+ randomNonce,
26
+ sameCode,
27
+ signAck,
28
+ signAnswer,
29
+ typedCode,
30
+ verifyHello,
31
+ verifyReveal,
32
+ verifyStatusAsk,
33
+ withinSkew,
34
+ SKEW_MS,
35
+ type LinkState,
36
+ } from "./hello";
37
+ import { KEY_CHANGED, keysChanged, pinPeer } from "./pin";
38
+ import { notifyLinked, notifyLinkRequest } from "./notify";
39
+
40
+ export const REQUEST_TTL_MS = 5 * 60_000;
41
+ export const MAX_OPEN = 3;
42
+ export const MAX_WRONG = 3;
43
+ const WINDOW_MS = 10 * 60_000;
44
+ const PER_SENDER = 5;
45
+ const GLOBAL = 20;
46
+ // Hellos that get as far as the account list, from anyone: bounds the
47
+ // signature checks and list fetches a stranger can cause. One address (an
48
+ // IPv6 /64) is held to a share of it first, so it can't use it all up.
49
+ const UNPROVEN = 120;
50
+ const PER_SOURCE = 20;
51
+
52
+ interface Incoming {
53
+ id: string;
54
+ peer: AccountPeer;
55
+ aSign: string;
56
+ aBox: string;
57
+ commit: string;
58
+ bNonce: string;
59
+ code: string | null;
60
+ confirm: string | null;
61
+ state: "revealing" | LinkState;
62
+ why: string | null;
63
+ wrong: number;
64
+ createdAt: number;
65
+ }
66
+
67
+ export interface PairingDeps {
68
+ identity: () => Identity | null;
69
+ boxPub: () => string;
70
+ listed: (machineId: string) => Promise<AccountPeer | null>;
71
+ allowed: () => Promise<boolean | null>;
72
+ pin: (p: AccountPeer & { boxPub: string }) => string;
73
+ onRequest: (name: string) => void;
74
+ onLinked: (name: string) => void;
75
+ now: () => number;
76
+ }
77
+
78
+ interface Store {
79
+ requests: Map<string, Incoming>;
80
+ hellos: { at: number; from: string | null; source: string }[];
81
+ seen: Map<string, number>;
82
+ }
83
+
84
+ const g = globalThis as unknown as { __agentosPairingIn?: Store };
85
+ const store = (): Store =>
86
+ (g.__agentosPairingIn ??= {
87
+ requests: new Map(),
88
+ hellos: [],
89
+ seen: new Map(),
90
+ });
91
+
92
+ /** Tests: forget every request and every limit. */
93
+ export const resetIncoming = () => {
94
+ g.__agentosPairingIn = undefined;
95
+ };
96
+
97
+ let box: string | null = null;
98
+ export const realPairingDeps = (): PairingDeps => ({
99
+ identity: () => currentIdentity(),
100
+ boxPub: () => (box ??= loadBoxKey().publicRaw),
101
+ listed: listedPeer,
102
+ allowed: linkingAllowed,
103
+ pin: pinPeer,
104
+ onRequest: notifyLinkRequest,
105
+ onLinked: notifyLinked,
106
+ now: Date.now,
107
+ });
108
+
109
+ export interface Reply {
110
+ status: number;
111
+ body: Record<string, unknown>;
112
+ }
113
+
114
+ const no = (status: number, error: string): Reply => ({
115
+ status,
116
+ body: { error },
117
+ });
118
+
119
+ function sweep(s: Store, now: number) {
120
+ for (const r of s.requests.values()) {
121
+ if (r.state !== "accepted" && now - r.createdAt > REQUEST_TTL_MS) {
122
+ if (r.state === "revealing" || r.state === "pending") r.state = "expired";
123
+ }
124
+ // Finished ones are kept a little so the sender can learn how it ended.
125
+ if (now - r.createdAt > 2 * REQUEST_TTL_MS) s.requests.delete(r.id);
126
+ }
127
+ s.hellos = s.hellos.filter((h) => now - h.at < WINDOW_MS);
128
+ for (const [k, until] of s.seen) if (until < now) s.seen.delete(k);
129
+ }
130
+
131
+ const open = (r: Incoming) => r.state === "revealing" || r.state === "pending";
132
+
133
+ /** A nonce, once per sender, for as long as its timestamp would pass. */
134
+ function fresh(s: Store, from: string, nonce: string, ts: number): boolean {
135
+ const k = `${from}:${nonce}`;
136
+ if (s.seen.has(k)) return false;
137
+ s.seen.set(k, ts + SKEW_MS);
138
+ return true;
139
+ }
140
+
141
+ /** POST /api/peer/hello */
142
+ export async function receiveHello(
143
+ body: unknown,
144
+ deps: PairingDeps = realPairingDeps(),
145
+ source = "unknown"
146
+ ): Promise<Reply> {
147
+ const s = store();
148
+ const now = deps.now();
149
+ sweep(s, now);
150
+ const hello = parseHello(body);
151
+ if (!hello) return no(400, "bad hello");
152
+ const self = deps.identity();
153
+ if (!self || hello.to !== self.machineId)
154
+ return no(403, "this isn't the machine you meant");
155
+ if (!withinSkew(hello.ts, now))
156
+ return no(401, "stale: the clocks on the two machines differ");
157
+ if (
158
+ s.hellos.filter((h) => h.source === source).length >= PER_SOURCE ||
159
+ s.hellos.length >= UNPROVEN
160
+ )
161
+ return no(429, "too many requests");
162
+ s.hellos.push({ at: now, from: null, source });
163
+ const allowed = await deps.allowed();
164
+ if (allowed !== true)
165
+ return no(402, "Linking machines needs AgentOS Connect Pro");
166
+ const listed = await deps.listed(hello.from);
167
+ // Only a key this machine's own account lists for that machine may ask.
168
+ if (!listed || listed.signPub !== hello.sign_pub)
169
+ return no(403, "that machine isn't on this account");
170
+ if (!verifyHello(hello)) return no(401, "bad signature");
171
+ if (!fresh(s, hello.from, hello.nonce, hello.ts)) return no(401, "replay");
172
+ if (
173
+ keysChanged(hello.from, { signPub: hello.sign_pub, boxPub: hello.box_pub })
174
+ )
175
+ return no(409, KEY_CHANGED);
176
+ const proven = s.hellos.filter((h) => h.from !== null);
177
+ if (
178
+ proven.length >= GLOBAL ||
179
+ proven.filter((h) => h.from === hello.from).length >= PER_SENDER
180
+ )
181
+ return no(429, "too many link requests; wait a few minutes");
182
+ s.hellos[s.hellos.length - 1].from = hello.from;
183
+ for (const old of s.requests.values())
184
+ if (old.peer.id === hello.from && open(old)) {
185
+ old.state = "declined";
186
+ old.why = "replaced by a newer request";
187
+ }
188
+ if ([...s.requests.values()].filter(open).length >= MAX_OPEN)
189
+ return no(429, "too many link requests waiting here");
190
+
191
+ const r: Incoming = {
192
+ id: randomNonce(16),
193
+ peer: listed,
194
+ aSign: hello.sign_pub,
195
+ aBox: hello.box_pub,
196
+ commit: hello.commit,
197
+ bNonce: randomNonce(32),
198
+ code: null,
199
+ confirm: null,
200
+ state: "revealing",
201
+ why: null,
202
+ wrong: 0,
203
+ createdAt: now,
204
+ };
205
+ s.requests.set(r.id, r);
206
+ const ack = signAck(
207
+ self.signKey,
208
+ {
209
+ request_id: r.id,
210
+ sign_pub: self.signPub,
211
+ box_pub: deps.boxPub(),
212
+ b_nonce: r.bNonce,
213
+ },
214
+ hello
215
+ );
216
+ return { status: 200, body: { ...ack } };
217
+ }
218
+
219
+ /** Finds the request a signed reveal or status call is about. */
220
+ function askedAbout(
221
+ s: Store,
222
+ ask: {
223
+ from: string;
224
+ to: string;
225
+ ts: number;
226
+ nonce: string;
227
+ request_id: string;
228
+ },
229
+ self: Identity | null,
230
+ now: number
231
+ ): Incoming | Reply {
232
+ if (!self || ask.to !== self.machineId)
233
+ return no(403, "this isn't the machine you meant");
234
+ if (!withinSkew(ask.ts, now)) return no(401, "stale");
235
+ const r = s.requests.get(ask.request_id);
236
+ if (!r || r.peer.id !== ask.from) return no(404, "no such request");
237
+ return r;
238
+ }
239
+
240
+ /** POST /api/peer/hello/reveal */
241
+ export function receiveReveal(
242
+ body: unknown,
243
+ deps: PairingDeps = realPairingDeps()
244
+ ): Reply {
245
+ const s = store();
246
+ const now = deps.now();
247
+ sweep(s, now);
248
+ const reveal = parseReveal(body);
249
+ if (!reveal) return no(400, "bad reveal");
250
+ const self = deps.identity();
251
+ const r = askedAbout(s, reveal, self, now);
252
+ if (!("id" in r)) return r;
253
+ if (!verifyReveal(reveal, r.aSign)) return no(401, "bad signature");
254
+ if (!fresh(s, reveal.from, reveal.nonce, reveal.ts)) return no(401, "replay");
255
+ if (r.state !== "revealing")
256
+ return {
257
+ status: 200,
258
+ body: { ...signAnswer(self!.signKey, r.state, reveal) },
259
+ };
260
+ if (commitTo(r.aBox, r.aSign, reveal.a_nonce) !== r.commit) {
261
+ // Whoever sent this chose its keys after seeing ours: void it.
262
+ r.state = "declined";
263
+ r.why = "its keys didn't match what it committed to";
264
+ return no(403, "that doesn't match the hello");
265
+ }
266
+ const transcript = {
267
+ from: r.peer.id,
268
+ to: self!.machineId,
269
+ aSign: r.aSign,
270
+ aBox: r.aBox,
271
+ bSign: self!.signPub,
272
+ bBox: deps.boxPub(),
273
+ aNonce: reveal.a_nonce,
274
+ bNonce: r.bNonce,
275
+ };
276
+ r.code = deriveCode(transcript);
277
+ r.confirm = deriveCode(transcript, "confirm");
278
+ r.state = "pending";
279
+ deps.onRequest(r.peer.name);
280
+ return {
281
+ status: 200,
282
+ body: { ...signAnswer(self!.signKey, "pending", reveal) },
283
+ };
284
+ }
285
+
286
+ /** POST /api/peer/hello/status */
287
+ export function receiveStatus(
288
+ body: unknown,
289
+ deps: PairingDeps = realPairingDeps()
290
+ ): Reply {
291
+ const s = store();
292
+ const now = deps.now();
293
+ sweep(s, now);
294
+ const ask = parseStatusAsk(body);
295
+ if (!ask) return no(400, "bad status request");
296
+ const self = deps.identity();
297
+ const r = askedAbout(s, ask, self, now);
298
+ if (!("id" in r)) return r;
299
+ if (!verifyStatusAsk(ask, r.aSign)) return no(401, "bad signature");
300
+ if (!fresh(s, ask.from, ask.nonce, ask.ts)) return no(401, "replay");
301
+ const state: LinkState = r.state === "revealing" ? "pending" : r.state;
302
+ return { status: 200, body: { ...signAnswer(self!.signKey, state, ask) } };
303
+ }
304
+
305
+ export interface PendingView {
306
+ id: string;
307
+ machineId: string;
308
+ name: string;
309
+ lastSeenAt: string | null;
310
+ expiresAt: number;
311
+ attemptsLeft: number;
312
+ }
313
+
314
+ /** Requests waiting for a person here to type the code. */
315
+ export function pendingRequests(now = Date.now()): PendingView[] {
316
+ const s = store();
317
+ sweep(s, now);
318
+ return [...s.requests.values()]
319
+ .filter((r) => r.state === "pending")
320
+ .map((r) => ({
321
+ id: r.id,
322
+ machineId: r.peer.id,
323
+ name: r.peer.name,
324
+ lastSeenAt: r.peer.lastSeenAt,
325
+ expiresAt: r.createdAt + REQUEST_TTL_MS,
326
+ attemptsLeft: MAX_WRONG - r.wrong,
327
+ }));
328
+ }
329
+
330
+ export type AcceptResult =
331
+ | { ok: true; hostId: string; name: string; confirmCode: string }
332
+ | { ok: false; status: number; error: string; attemptsLeft?: number };
333
+
334
+ /** The code a person typed here, from a trusted device (the route checks). */
335
+ export async function acceptRequest(
336
+ id: string,
337
+ input: unknown,
338
+ deps: PairingDeps = realPairingDeps()
339
+ ): Promise<AcceptResult> {
340
+ const s = store();
341
+ sweep(s, deps.now());
342
+ const r = s.requests.get(id);
343
+ if (!r || r.state !== "pending" || !r.code || !r.confirm)
344
+ return {
345
+ ok: false,
346
+ status: 404,
347
+ error: "That request has ended. Start again from the other machine.",
348
+ };
349
+ const code = typedCode(input);
350
+ if (!code)
351
+ return {
352
+ ok: false,
353
+ status: 400,
354
+ error: "Type the 6 digits the other machine shows.",
355
+ };
356
+ if (!sameCode(code, r.code)) {
357
+ r.wrong += 1;
358
+ if (r.wrong >= MAX_WRONG) {
359
+ r.state = "declined";
360
+ r.why = "wrong code 3 times";
361
+ return {
362
+ ok: false,
363
+ status: 403,
364
+ error:
365
+ "Wrong code 3 times: the request is void. Start again from the other machine.",
366
+ attemptsLeft: 0,
367
+ };
368
+ }
369
+ return {
370
+ ok: false,
371
+ status: 403,
372
+ error: "That code doesn't match.",
373
+ attemptsLeft: MAX_WRONG - r.wrong,
374
+ };
375
+ }
376
+ if ((await deps.allowed()) !== true)
377
+ return {
378
+ ok: false,
379
+ status: 402,
380
+ error: "Linking machines needs AgentOS Connect Pro.",
381
+ };
382
+ let hostId: string;
383
+ try {
384
+ hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
385
+ } catch (err) {
386
+ r.state = "declined";
387
+ r.why = "it couldn't be linked here";
388
+ const error = err instanceof Error ? err.message : String(err);
389
+ return { ok: false, status: 409, error };
390
+ }
391
+ r.state = "accepted";
392
+ deps.onLinked(r.peer.name);
393
+ // The person types this on the other machine, which pins this one then.
394
+ return { ok: true, hostId, name: r.peer.name, confirmCode: r.confirm };
395
+ }
396
+
397
+ export function declineRequest(id: string): boolean {
398
+ const r = store().requests.get(id);
399
+ if (!r || !open(r)) return false;
400
+ r.state = "declined";
401
+ r.why = "declined here";
402
+ return true;
403
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pins a machine after pairing: its keys on a Connect host of its own, the
3
+ * one it already has if it paired before. A re-pair never changes the keys
4
+ * of a pin: a machine whose keys changed is unlinked first, by a person.
5
+ */
6
+
7
+ import { createConnectHost, listRemoteHosts } from "../hosts";
8
+ import { pinForMachine, savePin } from "./links";
9
+
10
+ export interface Paired {
11
+ id: string;
12
+ name: string;
13
+ hostname: string;
14
+ signPub: string;
15
+ boxPub: string;
16
+ }
17
+
18
+ /** A name no other machine here has, so `aos move <name>` stays clear. */
19
+ function freeName(name: string): string {
20
+ const taken = new Set(listRemoteHosts().map((h) => h.name.toLowerCase()));
21
+ if (!taken.has(name.toLowerCase()) && name.toLowerCase() !== "local")
22
+ return name;
23
+ for (let n = 2; ; n++)
24
+ if (!taken.has(`${name} (${n})`.toLowerCase())) return `${name} (${n})`;
25
+ }
26
+
27
+ export const KEY_CHANGED =
28
+ "This machine's keys changed since it was linked. Unlink it here first, then link it again.";
29
+
30
+ /** Whether a machine already linked here holds other keys than these. */
31
+ export function keysChanged(
32
+ machineId: string,
33
+ keys: { signPub: string; boxPub?: string }
34
+ ): boolean {
35
+ const pin = pinForMachine(machineId);
36
+ return (
37
+ !!pin &&
38
+ (pin.signPub !== keys.signPub ||
39
+ (keys.boxPub !== undefined && pin.boxPub !== keys.boxPub))
40
+ );
41
+ }
42
+
43
+ export function pinPeer(p: Paired): string {
44
+ const pinned = pinForMachine(p.id);
45
+ if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
46
+ const hostId = pinned?.hostId ?? createConnectHost(freeName(p.name)).id;
47
+ savePin({
48
+ hostId,
49
+ machineId: p.id,
50
+ hostname: p.hostname,
51
+ signPub: p.signPub,
52
+ boxPub: p.boxPub,
53
+ });
54
+ return hostId;
55
+ }
@@ -119,16 +119,35 @@ export function createBodyBudget(o: Partial<BudgetLimits> = {}): BodyBudget {
119
119
  /**
120
120
  * Where a request came from: the visitor's address Cloudflare stamps on a
121
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.
122
123
  */
123
124
  export function sourceOf(req: IncomingMessage): string {
125
+ let addr = req.socket.remoteAddress ?? "unknown";
124
126
  if (isTunnelSocket(req.socket)) {
125
127
  const cf = String(req.headers["cf-connecting-ip"] ?? "").trim();
126
- if (isIP(cf)) return cf;
128
+ if (isIP(cf)) addr = cf;
127
129
  }
128
- return req.socket.remoteAddress ?? "unknown";
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`;
129
145
  }
130
146
 
131
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;
132
151
  /** How long the first bytes may take before a rate is required. */
133
152
  graceMs: number;
134
153
  /** Bytes per second the body must keep up after the grace period, */
@@ -138,6 +157,7 @@ export interface Pace {
138
157
  }
139
158
 
140
159
  export const DEFAULT_PACE: Pace = {
160
+ headerMs: 3_000,
141
161
  graceMs: 10_000,
142
162
  minRate: 512 * 1024,
143
163
  windowMs: 5_000,
@@ -183,7 +203,7 @@ export function readEnvelope(
183
203
  if (v instanceof Error) reject(v);
184
204
  else resolve(v);
185
205
  };
186
- const { graceMs, minRate, windowMs } = o.pace;
206
+ const { headerMs, graceMs, minRate, windowMs } = o.pace;
187
207
  // (time, bytes so far), back to the start of the current window.
188
208
  const samples = [{ t: started, total: 0 }];
189
209
  const timer = setInterval(
@@ -192,12 +212,14 @@ export function readEnvelope(
192
212
  samples.push({ t: now, total });
193
213
  while (samples.length > 1 && samples[1].t <= now - windowMs)
194
214
  samples.shift();
215
+ if (!proven && now - started > headerMs)
216
+ return finish({ tooSlow: true });
195
217
  if (now - started <= graceMs) return;
196
218
  const base = samples[0];
197
219
  if (total - base.total < ((now - base.t) / 1000) * minRate)
198
220
  finish({ tooSlow: true });
199
221
  },
200
- Math.max(25, Math.min(1000, graceMs / 4, windowMs / 4))
222
+ Math.max(25, Math.min(1000, headerMs / 4, graceMs / 4, windowMs / 4))
201
223
  );
202
224
  req.on("data", (chunk: Buffer) => {
203
225
  if (done) return;
@@ -211,10 +233,17 @@ export function readEnvelope(
211
233
  const len = head.readUInt32BE(0);
212
234
  if (len > MAX_HEADER) return finish({ refusal: "bad_envelope" });
213
235
  if (head.length < 4 + len + HEADER_SIG) return;
214
- const seen = o.early(
215
- head.subarray(4, 4 + len),
216
- head.subarray(4 + len, 4 + len + HEADER_SIG)
217
- );
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
+ }
218
247
  if ("refusal" in seen) return finish({ refusal: seen.refusal });
219
248
  if (!o.ticket.prove(seen.peer)) return finish({ refusal: "busy" });
220
249
  proven = true;
@@ -177,6 +177,13 @@ export async function handlePeerRpc(
177
177
  return null;
178
178
  };
179
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
+
180
187
  if (!deps.anyActive()) return plain(res, 404, "not found");
181
188
  const ticket = deps.budget.open(sourceOf(req));
182
189
  if (!ticket) return void refuse("busy");
@@ -193,6 +200,17 @@ export async function handlePeerRpc(
193
200
  return { refusal: "bad_sig" };
194
201
  const now = deps.now?.() ?? Date.now();
195
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}`;
196
214
  return { peer: header.from };
197
215
  },
198
216
  ticket,
@@ -209,7 +227,7 @@ export async function handlePeerRpc(
209
227
  self: self.machineId,
210
228
  boxKey: deps.boxKey().privateKey,
211
229
  signKeyFor,
212
- replay: deps.replay,
230
+ replay: spent,
213
231
  now: deps.now?.(),
214
232
  });
215
233
  };
@@ -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,
@@ -18,6 +18,9 @@ export const DEVICE_COOKIE = "aos_device";
18
18
  export const DEVICE_HEADER = "x-agentos-device";
19
19
  export const TRUST_HEADER = "x-agentos-trust";
20
20
  export const REMOTE_HEADER = "x-agentos-remote";
21
+ // Who sent it, for per-sender limits: the visitor's address on a Connect
22
+ // request (lib/peer/rpc-intake sourceOf), an IPv6 address by its /64.
23
+ export const SOURCE_HEADER = "x-agentos-source";
21
24
  // The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
22
25
  // and the linked machine it verified. Never trusted from anywhere else.
23
26
  export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
@@ -81,12 +84,17 @@ const FORWARDING = new Set([
81
84
  const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
82
85
 
83
86
  // Reached without a token: the pairing page, what it needs to render, the
84
- // claim it posts, and a linked machine's rpc (its envelope is the proof). Exact paths, plus a few static asset folders whose
85
- // paths may only use plain characters (no traversal, no encoding).
87
+ // claim it posts, a linked machine's rpc (its envelope is the proof) and
88
+ // the pairing hellos (signed by a key the account lists). Exact paths, plus
89
+ // a few static asset folders whose paths may only use plain characters (no
90
+ // traversal, no encoding).
86
91
  const PUBLIC_EXACT = new Set([
87
92
  "/pair",
88
93
  "/api/pair/claim",
89
94
  "/api/peer/rpc",
95
+ "/api/peer/hello",
96
+ "/api/peer/hello/reveal",
97
+ "/api/peer/hello/status",
90
98
  "/manifest.webmanifest",
91
99
  "/manifest.json",
92
100
  "/favicon.ico",
@@ -14,6 +14,7 @@ import {
14
14
  DEVICE_HEADER,
15
15
  TRUST_HEADER,
16
16
  REMOTE_HEADER,
17
+ SOURCE_HEADER,
17
18
  PEER_HEADER,
18
19
  PEER_INTERNAL_HEADER,
19
20
  type AuthPolicy,
@@ -25,6 +26,7 @@ import { tailscaleAddresses } from "./net";
25
26
  import { networkSetting } from "./network-settings";
26
27
  import { pairLocation } from "../session-url";
27
28
  import { peerSecret } from "../peer/internal";
29
+ import { sourceOf } from "../peer/rpc-intake";
28
30
 
29
31
  // Interface addresses change rarely; don't re-read them on every request.
30
32
  let tailnetCache = { at: 0, ips: [] as string[] };
@@ -63,6 +65,7 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
63
65
  delete req.headers[PEER_HEADER];
64
66
  const tunnel = isTunnelSocket(req.socket);
65
67
  req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
68
+ req.headers[SOURCE_HEADER] = sourceOf(req);
66
69
  let result: AuthResult;
67
70
  try {
68
71
  result = authorize(
@@ -4,6 +4,7 @@ import { waitForExit } from "../chat/worker/client";
4
4
  import { endTmuxSession } from "../tmux/end";
5
5
  import { releaseOnEnd, shutTaskSimulators } from "../simulator";
6
6
  import { forgetPlanWait } from "../models/plan-step";
7
+ import { kickWaits } from "../bus/waits/kick";
7
8
 
8
9
  type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
9
10
  Partial<Pick<Session, "worktree_path" | "branch_name">>;
@@ -20,4 +21,6 @@ export async function endSessionAgent(s: Agent): Promise<void> {
20
21
  shutTaskSimulators(s);
21
22
  void waitForExit(s.id).catch(() => {});
22
23
  await endTmuxSession(s.host_id, s.tmux_name);
24
+ // Whoever waits on it is told now, not at the next sweep.
25
+ kickWaits();
23
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.698",
3
+ "version": "0.3.700",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/agent-os CHANGED
@@ -83,6 +83,8 @@ case "${1:-}" in
83
83
  pair) node "$SCRIPT_DIR/pair.mjs" "$PORT" ;;
84
84
  connect) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/connect/cli.ts "$@") ;;
85
85
  disconnect) (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/connect/cli.ts --off) ;;
86
+ link) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/peer/cli.ts "$PORT" "$@") ;;
87
+ unlink) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/peer/cli.ts "$PORT" --unlink "$@") ;;
86
88
  passkeys) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/security/passkeys-cli.ts "$@") ;;
87
89
  logs) cmd_logs ;;
88
90
  update) cmd_update ;;