@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,270 @@
1
+ /**
2
+ * The other machines on this machine's Connect account, and whether its plan
3
+ * includes linking. Connect only introduces: a listed key is who may ask to
4
+ * pair, never who is trusted (that takes the code typed on the target).
5
+ *
6
+ * Kept in memory, shared through globalThis by the server and the routes.
7
+ * The list is fetched when it's older than a minute and someone needs it,
8
+ * and marked stale when the heartbeat's peers_etag changes; fetches are
9
+ * shared and spaced, so a flood of hellos can't hammer the service.
10
+ */
11
+
12
+ import crypto from "crypto";
13
+ import { readConnect } from "../connect/config";
14
+ import {
15
+ listPeers,
16
+ type Beat,
17
+ type Entitlements,
18
+ type PeerList,
19
+ } from "../connect/control-api";
20
+ import { peerHostAllowed } from "./client";
21
+ import { rawPublic } from "./keys";
22
+ import { cleanMachineName } from "./name";
23
+ import { reconcilePins, type AccountSays } from "./revoke";
24
+ import { getSetting, putSetting } from "../settings-store";
25
+
26
+ export interface AccountPeer {
27
+ id: string;
28
+ name: string;
29
+ hostname: string;
30
+ /** Its Ed25519 public key, base64url of the raw 32 bytes. */
31
+ signPub: string;
32
+ lastSeenAt: string | null;
33
+ version: string | null;
34
+ }
35
+
36
+ export interface AccountView {
37
+ enrolled: boolean;
38
+ entitlements: Entitlements | null;
39
+ peers: AccountPeer[];
40
+ /** When the list was last fetched, 0 if never. */
41
+ fetchedAt: number;
42
+ error: string | null;
43
+ /** Set when the service hasn't answered for a day: since when. */
44
+ uncheckedSince: number | null;
45
+ }
46
+
47
+ export const LIST_MAX_AGE_MS = 60_000;
48
+ export const UNCHECKED_NOTICE_MS = 24 * 3_600_000;
49
+ const OK_KEY = "peer.account_ok_at";
50
+ // The last answer is written down at most this often (it survives restarts).
51
+ const OK_SAVE_MS = 10 * 60_000;
52
+ // Never more often than this, whoever asks.
53
+ const MIN_GAP_MS = 5_000;
54
+
55
+ interface State {
56
+ entitlements: Entitlements | null;
57
+ etag: string | null;
58
+ peers: AccountPeer[];
59
+ fetchedAt: number;
60
+ triedAt: number;
61
+ stale: boolean;
62
+ error: string | null;
63
+ inflight: Promise<void> | null;
64
+ okAt: number;
65
+ okSavedAt: number;
66
+ }
67
+
68
+ export type Fetcher = () => Promise<PeerList | null>;
69
+
70
+ const g = globalThis as unknown as {
71
+ __agentosPeerAccount?: State;
72
+ __agentosPeerFetcher?: Fetcher;
73
+ };
74
+
75
+ const fresh = (): State => ({
76
+ entitlements: null,
77
+ etag: null,
78
+ peers: [],
79
+ fetchedAt: 0,
80
+ triedAt: 0,
81
+ stale: false,
82
+ error: null,
83
+ inflight: null,
84
+ okAt: 0,
85
+ okSavedAt: 0,
86
+ });
87
+
88
+ const state = () => (g.__agentosPeerAccount ??= fresh());
89
+
90
+ /** The signed call, or null when this machine isn't enrolled. */
91
+ const realFetcher: Fetcher = async () => {
92
+ const read = readConnect();
93
+ if (!read.ok) return null;
94
+ return listPeers(read.files.config, read.files.machineKey);
95
+ };
96
+
97
+ /** Tests: a fake Connect service, and a clean slate. */
98
+ export function setAccountFetcher(f: Fetcher | null) {
99
+ g.__agentosPeerFetcher = f ?? undefined;
100
+ g.__agentosPeerAccount = fresh();
101
+ }
102
+
103
+ /** Control's SPKI PEM as the raw key the pins use, or null. */
104
+ export function rawFromSpki(pem: string): string | null {
105
+ try {
106
+ const key = crypto.createPublicKey(pem);
107
+ return key.asymmetricKeyType === "ed25519" ? rawPublic(key) : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** The service answered: links can be checked against it again. */
114
+ export function noteAccountOk(now = Date.now()) {
115
+ const s = state();
116
+ s.okAt = now;
117
+ if (now - s.okSavedAt < OK_SAVE_MS) return;
118
+ s.okSavedAt = now;
119
+ try {
120
+ putSetting(OK_KEY, String(now));
121
+ } catch {
122
+ // Bookkeeping only.
123
+ }
124
+ }
125
+
126
+ /** When the service last answered; a first look starts the clock. */
127
+ function lastOk(now: number): number {
128
+ const s = state();
129
+ if (s.okAt) return s.okAt;
130
+ let saved = NaN;
131
+ try {
132
+ saved = Number(getSetting(OK_KEY));
133
+ } catch {
134
+ // No database: count from now.
135
+ }
136
+ if (Number.isFinite(saved) && saved > 0) return (s.okAt = saved);
137
+ noteAccountOk(now);
138
+ return now;
139
+ }
140
+
141
+ function applyToPins(says: AccountSays) {
142
+ try {
143
+ reconcilePins(says);
144
+ } catch (err) {
145
+ console.error("peer revoke:", err);
146
+ }
147
+ }
148
+
149
+ function adopt(list: PeerList) {
150
+ const s = state();
151
+ const peers: AccountPeer[] = [];
152
+ for (const m of list.peers) {
153
+ const signPub = rawFromSpki(m.publicKey);
154
+ // Only a Connect machine's own name: a lying list can't aim this
155
+ // machine anywhere else.
156
+ if (!signPub || !peerHostAllowed(m.hostname)) continue;
157
+ peers.push({
158
+ id: m.id,
159
+ name: cleanMachineName(m.name),
160
+ hostname: m.hostname,
161
+ signPub,
162
+ lastSeenAt: m.lastSeenAt,
163
+ version: m.version,
164
+ });
165
+ }
166
+ s.peers = peers;
167
+ if (list.entitlements) s.entitlements = list.entitlements;
168
+ if (list.peersEtag) s.etag = list.peersEtag;
169
+ s.fetchedAt = Date.now();
170
+ s.stale = false;
171
+ s.error = null;
172
+ noteAccountOk(s.fetchedAt);
173
+ applyToPins({
174
+ linkMachines: s.entitlements?.linkMachines ?? null,
175
+ listed: new Map(peers.map((p) => [p.id, p.signPub])),
176
+ });
177
+ }
178
+
179
+ async function refresh(): Promise<void> {
180
+ const s = state();
181
+ if (s.inflight) return s.inflight;
182
+ if (Date.now() - s.triedAt < MIN_GAP_MS) return;
183
+ s.triedAt = Date.now();
184
+ s.inflight = (async () => {
185
+ try {
186
+ const list = await (g.__agentosPeerFetcher ?? realFetcher)();
187
+ if (list) adopt(list);
188
+ else {
189
+ s.peers = [];
190
+ s.error = "This machine isn't connected to AgentOS Connect.";
191
+ }
192
+ } catch (err) {
193
+ // The last list stays; only its age says it's old.
194
+ s.error = `Can't reach your AgentOS account: ${
195
+ err instanceof Error ? err.message : String(err)
196
+ }`.slice(0, 300);
197
+ } finally {
198
+ s.inflight = null;
199
+ }
200
+ })();
201
+ return s.inflight;
202
+ }
203
+
204
+ const enrolled = () =>
205
+ g.__agentosPeerFetcher ? true : readConnect().ok === true;
206
+
207
+ /** The account's machines, fetched first if the list is older than maxAgeMs. */
208
+ export async function accountView(
209
+ maxAgeMs = LIST_MAX_AGE_MS
210
+ ): Promise<AccountView> {
211
+ if (!enrolled())
212
+ return {
213
+ enrolled: false,
214
+ entitlements: null,
215
+ peers: [],
216
+ fetchedAt: 0,
217
+ error: null,
218
+ uncheckedSince: null,
219
+ };
220
+ const s = state();
221
+ if (s.stale || Date.now() - s.fetchedAt > maxAgeMs) await refresh();
222
+ const now = Date.now();
223
+ const ok = lastOk(now);
224
+ return {
225
+ enrolled: true,
226
+ entitlements: s.entitlements,
227
+ peers: s.peers,
228
+ fetchedAt: s.fetchedAt,
229
+ error: s.error,
230
+ uncheckedSince: now - ok > UNCHECKED_NOTICE_MS ? ok : null,
231
+ };
232
+ }
233
+
234
+ /** The machine as the account lists it now, or null when it isn't listed. */
235
+ export async function listedPeer(
236
+ machineId: string
237
+ ): Promise<AccountPeer | null> {
238
+ const view = await accountView();
239
+ return view.peers.find((p) => p.id === machineId) ?? null;
240
+ }
241
+
242
+ /** Whether the plan includes linking: null until the service has said. */
243
+ export async function linkingAllowed(): Promise<boolean | null> {
244
+ const { entitlements } = await accountView();
245
+ return entitlements ? entitlements.linkMachines : null;
246
+ }
247
+
248
+ /**
249
+ * What a heartbeat says: the service is there, the plan now, and whether the
250
+ * list changed, which is fetched again at once so a machine removed from the
251
+ * account is suspended within a beat or so.
252
+ */
253
+ export function noteHeartbeat(beat: Beat) {
254
+ const s = state();
255
+ noteAccountOk();
256
+ const plan = beat.entitlements?.linkMachines;
257
+ const planChanged =
258
+ plan !== undefined && plan !== s.entitlements?.linkMachines;
259
+ if (beat.entitlements) s.entitlements = beat.entitlements;
260
+ if (beat.peersEtag && beat.peersEtag !== s.etag) {
261
+ s.etag = beat.peersEtag;
262
+ s.stale = true;
263
+ }
264
+ // A downgrade suspends now. An upgrade waits for a fresh list, so a pin
265
+ // whose machine left the account meanwhile isn't resumed on the way.
266
+ if (planChanged && plan === false)
267
+ applyToPins({ linkMachines: false, listed: null });
268
+ else if (planChanged) s.stale = true;
269
+ if (s.stale && enrolled()) void refresh();
270
+ }
@@ -67,7 +67,7 @@ export const PEER_ROUTES: readonly PeerRoute[] = [
67
67
  { method: "POST", pattern: "/api/peer/unlink" },
68
68
  { method: "GET", pattern: "/api/peer/ping" },
69
69
 
70
- // The work registry's exchange (not built yet: 404 until it is).
70
+ // The work registry's exchange.
71
71
  { method: "POST", pattern: "/api/claims/exchange" },
72
72
  ];
73
73
 
@@ -0,0 +1,24 @@
1
+ /** Entry for agent-os link / unlink (lib/peer/link-cli.ts). Argv: port, then the command. */
2
+
3
+ import { createInterface } from "readline/promises";
4
+ import { runLinkCli } from "./link-cli";
5
+
6
+ const [port, ...args] = process.argv.slice(2);
7
+ runLinkCli(args, {
8
+ base: `http://127.0.0.1:${Number(port) || 3011}`,
9
+ fetch,
10
+ out: (line) => console.log(line),
11
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
12
+ ask: async (prompt) => {
13
+ if (!process.stdin.isTTY) return null;
14
+ const rl = createInterface({
15
+ input: process.stdin,
16
+ output: process.stdout,
17
+ });
18
+ try {
19
+ return await rl.question(prompt);
20
+ } finally {
21
+ rl.close();
22
+ }
23
+ },
24
+ }).then((code) => process.exit(code));
@@ -15,7 +15,8 @@ import {
15
15
  sealRequest,
16
16
  } from "./envelope";
17
17
  import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
18
- import { notePinResult } from "./links";
18
+ import { notePinResult, unlinkedByPeer } from "./links";
19
+ import { UNLINKED_PROOF_HEADER, verifyUnlinked } from "./unlinked-proof";
19
20
  import type { HostLink } from "../hosts/remote-api";
20
21
  import { readCappedBytes } from "../hosts/read-capped-bytes";
21
22
 
@@ -67,12 +68,15 @@ export interface ClientDeps {
67
68
  identity: () => Identity | null;
68
69
  audit: (e: AuditEntry) => void;
69
70
  noteResult: (hostId: string, error: string | null) => void;
71
+ /** It proved it unlinked this machine: drop the pin here too. */
72
+ unlinked: (hostId: string, name: string) => void;
70
73
  }
71
74
 
72
75
  const realDeps: ClientDeps = {
73
76
  identity: () => currentIdentity(),
74
77
  audit: recordPeerCall,
75
78
  noteResult: notePinResult,
79
+ unlinked: unlinkedByPeer,
76
80
  };
77
81
 
78
82
  export async function peerCall(
@@ -155,6 +159,27 @@ export async function peerCall(
155
159
  }
156
160
  if (res.status !== 200) {
157
161
  const text = raw.subarray(0, 64).toString("utf8").trim();
162
+ if (
163
+ res.status === 403 &&
164
+ text === "unknown peer" &&
165
+ verifyUnlinked(
166
+ signPublic(peer.signPub),
167
+ {
168
+ from: peer.machineId,
169
+ to: identity.machineId,
170
+ nonce: sealed.nonce.toString("base64url"),
171
+ },
172
+ res.headers.get(UNLINKED_PROOF_HEADER)
173
+ )
174
+ ) {
175
+ audit("unknown_peer", 403, sealed.envelope.length);
176
+ deps.unlinked(link.hostId, link.hostName);
177
+ throw new PeerCallError(
178
+ `${link.hostName} unlinked this machine. Link it again in Machines.`,
179
+ true,
180
+ 403
181
+ );
182
+ }
158
183
  const outcome = REFUSALS[text];
159
184
  if (outcome && res.status >= 400 && res.status < 500) {
160
185
  audit(outcome, res.status, sealed.envelope.length);
@@ -0,0 +1,3 @@
1
+ /** "482913" as people read it. Pure, for the browser too. */
2
+ export const showCode = (code: string) =>
3
+ `${code.slice(0, 3)} ${code.slice(3)}`;
@@ -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
+ }