@saadnvd1/agent-os 0.3.698 → 0.3.715

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 (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,432 @@
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
+ // Tagged later through this reference: other hellos may have been pushed
163
+ // by then, while this one waited on the plan and the account list.
164
+ const entry: Store["hellos"][number] = { at: now, from: null, source };
165
+ s.hellos.push(entry);
166
+ const allowed = await deps.allowed();
167
+ if (allowed !== true)
168
+ return no(402, "Linking machines needs AgentOS Connect Pro");
169
+ const listed = await deps.listed(hello.from);
170
+ // Only a key this machine's own account lists for that machine may ask.
171
+ if (!listed || listed.signPub !== hello.sign_pub)
172
+ return no(403, "that machine isn't on this account");
173
+ if (!verifyHello(hello)) return no(401, "bad signature");
174
+ if (!fresh(s, hello.from, hello.nonce, hello.ts)) return no(401, "replay");
175
+ if (
176
+ keysChanged(hello.from, { signPub: hello.sign_pub, boxPub: hello.box_pub })
177
+ )
178
+ return no(409, KEY_CHANGED);
179
+ const proven = s.hellos.filter((h) => h.from !== null);
180
+ if (
181
+ proven.length >= GLOBAL ||
182
+ proven.filter((h) => h.from === hello.from).length >= PER_SENDER
183
+ )
184
+ return no(429, "too many link requests; wait a few minutes");
185
+ entry.from = hello.from;
186
+ for (const old of s.requests.values())
187
+ if (old.peer.id === hello.from && open(old)) {
188
+ old.state = "declined";
189
+ old.why = "replaced by a newer request";
190
+ }
191
+ if ([...s.requests.values()].filter(open).length >= MAX_OPEN)
192
+ return no(429, "too many link requests waiting here");
193
+
194
+ const r: Incoming = {
195
+ id: randomNonce(16),
196
+ peer: listed,
197
+ aSign: hello.sign_pub,
198
+ aBox: hello.box_pub,
199
+ commit: hello.commit,
200
+ bNonce: randomNonce(32),
201
+ code: null,
202
+ confirm: null,
203
+ state: "revealing",
204
+ why: null,
205
+ wrong: 0,
206
+ createdAt: now,
207
+ };
208
+ s.requests.set(r.id, r);
209
+ const ack = signAck(
210
+ self.signKey,
211
+ {
212
+ request_id: r.id,
213
+ sign_pub: self.signPub,
214
+ box_pub: deps.boxPub(),
215
+ b_nonce: r.bNonce,
216
+ },
217
+ hello
218
+ );
219
+ return { status: 200, body: { ...ack } };
220
+ }
221
+
222
+ /** Finds the request a signed reveal or status call is about. */
223
+ function askedAbout(
224
+ s: Store,
225
+ ask: {
226
+ from: string;
227
+ to: string;
228
+ ts: number;
229
+ nonce: string;
230
+ request_id: string;
231
+ },
232
+ self: Identity | null,
233
+ now: number
234
+ ): Incoming | Reply {
235
+ if (!self || ask.to !== self.machineId)
236
+ return no(403, "this isn't the machine you meant");
237
+ if (!withinSkew(ask.ts, now)) return no(401, "stale");
238
+ const r = s.requests.get(ask.request_id);
239
+ if (!r || r.peer.id !== ask.from) return no(404, "no such request");
240
+ return r;
241
+ }
242
+
243
+ /** POST /api/peer/hello/reveal */
244
+ export function receiveReveal(
245
+ body: unknown,
246
+ deps: PairingDeps = realPairingDeps()
247
+ ): Reply {
248
+ const s = store();
249
+ const now = deps.now();
250
+ sweep(s, now);
251
+ const reveal = parseReveal(body);
252
+ if (!reveal) return no(400, "bad reveal");
253
+ const self = deps.identity();
254
+ const r = askedAbout(s, reveal, self, now);
255
+ if (!("id" in r)) return r;
256
+ if (!verifyReveal(reveal, r.aSign)) return no(401, "bad signature");
257
+ if (!fresh(s, reveal.from, reveal.nonce, reveal.ts)) return no(401, "replay");
258
+ if (r.state !== "revealing")
259
+ return {
260
+ status: 200,
261
+ body: { ...signAnswer(self!.signKey, r.state, reveal) },
262
+ };
263
+ if (commitTo(r.aBox, r.aSign, reveal.a_nonce) !== r.commit) {
264
+ // Whoever sent this chose its keys after seeing ours: void it.
265
+ r.state = "declined";
266
+ r.why = "its keys didn't match what it committed to";
267
+ return no(403, "that doesn't match the hello");
268
+ }
269
+ const transcript = {
270
+ from: r.peer.id,
271
+ to: self!.machineId,
272
+ aSign: r.aSign,
273
+ aBox: r.aBox,
274
+ bSign: self!.signPub,
275
+ bBox: deps.boxPub(),
276
+ aNonce: reveal.a_nonce,
277
+ bNonce: r.bNonce,
278
+ };
279
+ r.code = deriveCode(transcript);
280
+ r.confirm = deriveCode(transcript, "confirm");
281
+ r.state = "pending";
282
+ deps.onRequest(r.peer.name);
283
+ return {
284
+ status: 200,
285
+ body: { ...signAnswer(self!.signKey, "pending", reveal) },
286
+ };
287
+ }
288
+
289
+ /** POST /api/peer/hello/status */
290
+ export function receiveStatus(
291
+ body: unknown,
292
+ deps: PairingDeps = realPairingDeps()
293
+ ): Reply {
294
+ const s = store();
295
+ const now = deps.now();
296
+ sweep(s, now);
297
+ const ask = parseStatusAsk(body);
298
+ if (!ask) return no(400, "bad status request");
299
+ const self = deps.identity();
300
+ const r = askedAbout(s, ask, self, now);
301
+ if (!("id" in r)) return r;
302
+ if (!verifyStatusAsk(ask, r.aSign)) return no(401, "bad signature");
303
+ if (!fresh(s, ask.from, ask.nonce, ask.ts)) return no(401, "replay");
304
+ const state: LinkState = r.state === "revealing" ? "pending" : r.state;
305
+ return { status: 200, body: { ...signAnswer(self!.signKey, state, ask) } };
306
+ }
307
+
308
+ export interface PendingView {
309
+ id: string;
310
+ machineId: string;
311
+ name: string;
312
+ lastSeenAt: string | null;
313
+ expiresAt: number;
314
+ attemptsLeft: number;
315
+ /** `accepted`: the code matched here; `confirmCode` goes on the other machine. */
316
+ state: "pending" | "accepted";
317
+ /** Only for a caller that could have accepted it (withCodes). */
318
+ confirmCode?: string;
319
+ }
320
+
321
+ /**
322
+ * Requests waiting for a person here to type the code, and accepted ones
323
+ * still within their 5 minutes, whose second code must survive a reload.
324
+ */
325
+ export function pendingRequests(
326
+ now = Date.now(),
327
+ o: { withCodes?: boolean } = {}
328
+ ): PendingView[] {
329
+ const s = store();
330
+ sweep(s, now);
331
+ return [...s.requests.values()]
332
+ .filter(
333
+ (r) =>
334
+ r.state === "pending" ||
335
+ (r.state === "accepted" && now - r.createdAt <= REQUEST_TTL_MS)
336
+ )
337
+ .map((r) => ({
338
+ id: r.id,
339
+ machineId: r.peer.id,
340
+ name: r.peer.name,
341
+ lastSeenAt: r.peer.lastSeenAt,
342
+ expiresAt: r.createdAt + REQUEST_TTL_MS,
343
+ attemptsLeft: MAX_WRONG - r.wrong,
344
+ state: r.state === "accepted" ? "accepted" : "pending",
345
+ ...(o.withCodes &&
346
+ r.state === "accepted" &&
347
+ r.confirm && { confirmCode: r.confirm }),
348
+ }));
349
+ }
350
+
351
+ export type AcceptResult =
352
+ | { ok: true; hostId: string; name: string; confirmCode: string }
353
+ | { ok: false; status: number; error: string; attemptsLeft?: number };
354
+
355
+ /** The code a person typed here, from a trusted device (the route checks). */
356
+ export async function acceptRequest(
357
+ id: string,
358
+ input: unknown,
359
+ deps: PairingDeps = realPairingDeps()
360
+ ): Promise<AcceptResult> {
361
+ const s = store();
362
+ sweep(s, deps.now());
363
+ const r = s.requests.get(id);
364
+ if (!r || r.state !== "pending" || !r.code || !r.confirm)
365
+ return {
366
+ ok: false,
367
+ status: 404,
368
+ error: "That request has ended. Start again from the other machine.",
369
+ };
370
+ const code = typedCode(input);
371
+ if (!code)
372
+ return {
373
+ ok: false,
374
+ status: 400,
375
+ error: "Type the 6 digits the other machine shows.",
376
+ };
377
+ if (!sameCode(code, r.code)) {
378
+ r.wrong += 1;
379
+ if (r.wrong >= MAX_WRONG) {
380
+ r.state = "declined";
381
+ r.why = "wrong code 3 times";
382
+ return {
383
+ ok: false,
384
+ status: 403,
385
+ error:
386
+ "Wrong code 3 times: the request is void. Start again from the other machine.",
387
+ attemptsLeft: 0,
388
+ };
389
+ }
390
+ return {
391
+ ok: false,
392
+ status: 403,
393
+ error: "That code doesn't match.",
394
+ attemptsLeft: MAX_WRONG - r.wrong,
395
+ };
396
+ }
397
+ if ((await deps.allowed()) !== true)
398
+ return {
399
+ ok: false,
400
+ status: 402,
401
+ error: "Linking machines needs AgentOS Connect Pro.",
402
+ };
403
+ // It may have been declined, replaced, expired or accepted meanwhile.
404
+ sweep(s, deps.now());
405
+ if (r.state !== "pending" || s.requests.get(id) !== r)
406
+ return {
407
+ ok: false,
408
+ status: 404,
409
+ error: "That request has ended. Start again from the other machine.",
410
+ };
411
+ let hostId: string;
412
+ try {
413
+ hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
414
+ } catch (err) {
415
+ r.state = "declined";
416
+ r.why = "it couldn't be linked here";
417
+ const error = err instanceof Error ? err.message : String(err);
418
+ return { ok: false, status: 409, error };
419
+ }
420
+ r.state = "accepted";
421
+ deps.onLinked(r.peer.name);
422
+ // The person types this on the other machine, which pins this one then.
423
+ return { ok: true, hostId, name: r.peer.name, confirmCode: r.confirm };
424
+ }
425
+
426
+ export function declineRequest(id: string): boolean {
427
+ const r = store().requests.get(id);
428
+ if (!r || !open(r)) return false;
429
+ r.state = "declined";
430
+ r.why = "declined here";
431
+ return true;
432
+ }
@@ -0,0 +1,62 @@
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, getHost, listRemoteHosts } from "../hosts";
8
+ import { pinForMachine, savePin, unlinkedMachine } from "./links";
9
+ import { cleanMachineName } from "./name";
10
+
11
+ export interface Paired {
12
+ id: string;
13
+ name: string;
14
+ hostname: string;
15
+ signPub: string;
16
+ boxPub: string;
17
+ }
18
+
19
+ /** A name no other machine here has, so `aos move <name>` stays clear. */
20
+ function freeName(name: string): string {
21
+ const taken = new Set(listRemoteHosts().map((h) => h.name.toLowerCase()));
22
+ if (!taken.has(name.toLowerCase()) && name.toLowerCase() !== "local")
23
+ return name;
24
+ for (let n = 2; ; n++)
25
+ if (!taken.has(`${name} (${n})`.toLowerCase())) return `${name} (${n})`;
26
+ }
27
+
28
+ export const KEY_CHANGED =
29
+ "This machine's keys changed since it was linked. Unlink it here first, then link it again.";
30
+
31
+ /** Whether a machine already linked here holds other keys than these. */
32
+ export function keysChanged(
33
+ machineId: string,
34
+ keys: { signPub: string; boxPub?: string }
35
+ ): boolean {
36
+ const pin = pinForMachine(machineId);
37
+ return (
38
+ !!pin &&
39
+ (pin.signPub !== keys.signPub ||
40
+ (keys.boxPub !== undefined && pin.boxPub !== keys.boxPub))
41
+ );
42
+ }
43
+
44
+ export function pinPeer(p: Paired): string {
45
+ const pinned = pinForMachine(p.id);
46
+ if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
47
+ // Linked again after an unlink: back on the host it had, if it's still here.
48
+ const was = unlinkedMachine(p.id)?.hostId;
49
+ const reuse = was && getHost(was)?.kind === "connect" ? was : null;
50
+ const hostId =
51
+ pinned?.hostId ??
52
+ reuse ??
53
+ createConnectHost(freeName(cleanMachineName(p.name))).id;
54
+ savePin({
55
+ hostId,
56
+ machineId: p.id,
57
+ hostname: p.hostname,
58
+ signPub: p.signPub,
59
+ boxPub: p.boxPub,
60
+ });
61
+ return hostId;
62
+ }
@@ -0,0 +1,7 @@
1
+ /** Why a Connect link is suspended (revoke.ts), shared with the UI. */
2
+
3
+ import { CONNECT_SITE } from "../connect/view";
4
+
5
+ export const REMOVED_FROM_ACCOUNT = "removed from your account";
6
+ export const NEEDS_PRO = "Linking machines needs Pro";
7
+ export const UPGRADE_URL = `${CONNECT_SITE}/settings/billing`;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Suspending links when the account says so, and lifting that when it says
3
+ * otherwise. A pin is never deleted here: a machine removed from the
4
+ * account by mistake comes back linked as soon as its key is listed again,
5
+ * with no new pairing. The account can only take trust away this way, never
6
+ * add it: a pin it didn't suspend is never touched, and a suspended pin is
7
+ * resumed only with the very key it was paired with.
8
+ */
9
+
10
+ import { allPins, resumePin, suspendPin, type PeerPin } from "./links";
11
+ import { NEEDS_PRO, REMOVED_FROM_ACCOUNT } from "./reasons";
12
+
13
+ export { NEEDS_PRO, REMOVED_FROM_ACCOUNT };
14
+
15
+ const OURS: ReadonlySet<string> = new Set([REMOVED_FROM_ACCOUNT, NEEDS_PRO]);
16
+
17
+ export interface AccountSays {
18
+ /** The plan's answer; null when it hasn't said. */
19
+ linkMachines: boolean | null;
20
+ /** Each listed machine's signing key, or null when this isn't a fresh list. */
21
+ listed: Map<string, string> | null;
22
+ }
23
+
24
+ /** What this pin's state should be, or null to leave it as it is. */
25
+ export function wanted(
26
+ pin: PeerPin,
27
+ says: AccountSays
28
+ ): { state: "active" } | { state: "suspended"; reason: string } | null {
29
+ if (pin.state === "suspended" && !OURS.has(pin.suspendedReason ?? ""))
30
+ return null;
31
+ if (says.linkMachines === false)
32
+ return { state: "suspended", reason: NEEDS_PRO };
33
+ const key = says.listed?.get(pin.machineId);
34
+ if (says.listed && key !== pin.signPub)
35
+ return { state: "suspended", reason: REMOVED_FROM_ACCOUNT };
36
+ if (pin.state === "active") return null;
37
+ const lifted =
38
+ pin.suspendedReason === NEEDS_PRO
39
+ ? says.linkMachines === true
40
+ : says.listed !== null && key === pin.signPub;
41
+ return lifted ? { state: "active" } : null;
42
+ }
43
+
44
+ export interface Changed {
45
+ hostId: string;
46
+ state: "active" | "suspended";
47
+ reason: string | null;
48
+ }
49
+
50
+ /** Applies what the account says to every pin; returns what changed. */
51
+ export function reconcilePins(
52
+ says: AccountSays,
53
+ pins: PeerPin[] = allPins()
54
+ ): Changed[] {
55
+ const changed: Changed[] = [];
56
+ for (const pin of pins) {
57
+ const want = wanted(pin, says);
58
+ if (!want) continue;
59
+ if (want.state === "active") {
60
+ resumePin(pin.hostId);
61
+ changed.push({ hostId: pin.hostId, state: "active", reason: null });
62
+ } else if (
63
+ pin.state !== "suspended" ||
64
+ pin.suspendedReason !== want.reason
65
+ ) {
66
+ suspendPin(pin.hostId, want.reason);
67
+ changed.push({
68
+ hostId: pin.hostId,
69
+ state: "suspended",
70
+ reason: want.reason,
71
+ });
72
+ }
73
+ }
74
+ return changed;
75
+ }
@@ -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;