@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
package/lib/peer/links.ts CHANGED
@@ -64,16 +64,18 @@ export const pinForHost = (hostId: string): PeerPin | null =>
64
64
 
65
65
  /** Pins a peer's keys to a Connect host. Pairing (S2) is the only caller. */
66
66
  export function savePin(pin: Omit<PeerPin, "state" | "suspendedReason">) {
67
- getDb()
68
- .prepare(
69
- `INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
67
+ const db = getDb();
68
+ db.prepare(`DELETE FROM peer_unlinked WHERE machine_id = ?`).run(
69
+ pin.machineId
70
+ );
71
+ db.prepare(
72
+ `INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
70
73
  VALUES (?, ?, ?, ?, ?)
71
74
  ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
72
75
  hostname = excluded.hostname, sign_pub = excluded.sign_pub,
73
76
  box_pub = excluded.box_pub, state = 'active', suspended_reason = NULL,
74
77
  paired_at = datetime('now'), last_error = NULL`
75
- )
76
- .run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
78
+ ).run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
77
79
  }
78
80
 
79
81
  export function suspendPin(hostId: string, reason: string) {
@@ -84,6 +86,112 @@ export function suspendPin(hostId: string, reason: string) {
84
86
  .run(reason, hostId);
85
87
  }
86
88
 
89
+ export function resumePin(hostId: string) {
90
+ getDb()
91
+ .prepare(
92
+ `UPDATE peer_links SET state = 'active', suspended_reason = NULL WHERE host_id = ?`
93
+ )
94
+ .run(hostId);
95
+ }
96
+
97
+ /** Every pin, for checking them against the account (revoke.ts). */
98
+ export const allPins = (): PeerPin[] =>
99
+ (getDb().prepare(SELECT).all() as Row[]).map((r) => toPin(r)!);
100
+
101
+ /** How long an unlinked machine is still answered "unknown peer", signed. */
102
+ export const UNLINKED_KEEP_MS = 30 * 24 * 3_600_000;
103
+
104
+ export interface Unlinked {
105
+ machineId: string;
106
+ hostId: string | null;
107
+ signPub: string;
108
+ /** The other machine unlinked it, rather than a person here. */
109
+ byPeer: boolean;
110
+ reason: string;
111
+ at: number;
112
+ }
113
+
114
+ interface UnlinkedRow {
115
+ machine_id: string;
116
+ host_id: string | null;
117
+ sign_pub: string;
118
+ by_peer: number;
119
+ reason: string;
120
+ at: number;
121
+ }
122
+
123
+ const toUnlinked = (r: UnlinkedRow | undefined): Unlinked | null =>
124
+ r
125
+ ? {
126
+ machineId: r.machine_id,
127
+ hostId: r.host_id,
128
+ signPub: r.sign_pub,
129
+ byPeer: r.by_peer === 1,
130
+ reason: r.reason,
131
+ at: r.at,
132
+ }
133
+ : null;
134
+
135
+ /** A machine unlinked here within the last 30 days, by its Connect id. */
136
+ export const unlinkedMachine = (
137
+ machineId: string,
138
+ now = Date.now()
139
+ ): Unlinked | null =>
140
+ toUnlinked(
141
+ getDb()
142
+ .prepare(`SELECT * FROM peer_unlinked WHERE machine_id = ? AND at > ?`)
143
+ .get(machineId, now - UNLINKED_KEEP_MS) as UnlinkedRow | undefined
144
+ );
145
+
146
+ export const unlinkedHost = (hostId: string): Unlinked | null =>
147
+ toUnlinked(
148
+ getDb()
149
+ .prepare(
150
+ `SELECT * FROM peer_unlinked WHERE host_id = ? ORDER BY at DESC LIMIT 1`
151
+ )
152
+ .get(hostId) as UnlinkedRow | undefined
153
+ );
154
+
155
+ export const hasRecentUnlinked = (now = Date.now()): boolean =>
156
+ getDb()
157
+ .prepare(`SELECT 1 FROM peer_unlinked WHERE at > ? LIMIT 1`)
158
+ .get(now - UNLINKED_KEEP_MS) !== undefined;
159
+
160
+ /**
161
+ * Removes a pin, keeping its signing key as unlinked (see the migration).
162
+ * The host stays: it may still hold sessions, and it says who unlinked it.
163
+ */
164
+ export function dropPin(
165
+ hostId: string,
166
+ o: { byPeer: boolean; reason: string },
167
+ now = Date.now()
168
+ ): PeerPin | null {
169
+ const pin = pinForHost(hostId);
170
+ if (!pin) return null;
171
+ const db = getDb();
172
+ db.transaction(() => {
173
+ db.prepare(`DELETE FROM peer_unlinked WHERE at <= ?`).run(
174
+ now - UNLINKED_KEEP_MS
175
+ );
176
+ db.prepare(
177
+ `INSERT INTO peer_unlinked (machine_id, host_id, sign_pub, by_peer, reason, at)
178
+ VALUES (?, ?, ?, ?, ?, ?)
179
+ ON CONFLICT(machine_id) DO UPDATE SET host_id = excluded.host_id,
180
+ sign_pub = excluded.sign_pub, by_peer = excluded.by_peer,
181
+ reason = excluded.reason, at = excluded.at`
182
+ ).run(
183
+ pin.machineId,
184
+ hostId,
185
+ pin.signPub,
186
+ o.byPeer ? 1 : 0,
187
+ o.reason.slice(0, 200),
188
+ now
189
+ );
190
+ db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
191
+ })();
192
+ return pin;
193
+ }
194
+
87
195
  /** Last outcome of a call to that peer, for Machines. */
88
196
  export function notePinResult(hostId: string, error: string | null) {
89
197
  getDb()
@@ -94,3 +202,7 @@ export function notePinResult(hostId: string, error: string | null) {
94
202
  )
95
203
  .run(...(error ? [error.slice(0, 300), hostId] : [hostId]));
96
204
  }
205
+
206
+ /** The other machine, `name`, unlinked this one: its signed call or refusal. */
207
+ export const unlinkedByPeer = (hostId: string, name: string): boolean =>
208
+ dropPin(hostId, { byPeer: true, reason: `Unlinked by ${name}` }) !== null;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What Machines shows under "On your AgentOS account": the account's other
3
+ * machines, each with whether it's linked here, and the plan.
4
+ */
5
+
6
+ import { getDb } from "../db";
7
+ import { accountView } from "./account";
8
+ import type { Entitlements } from "../connect/control-api";
9
+
10
+ export interface AccountMachine {
11
+ id: string;
12
+ name: string;
13
+ hostname: string;
14
+ online: boolean;
15
+ lastSeenAt: string | null;
16
+ version: string | null;
17
+ /** The Machines host it's linked as, when it is. */
18
+ hostId: string | null;
19
+ linkState: "active" | "suspended" | null;
20
+ suspendedReason: string | null;
21
+ }
22
+
23
+ export interface AccountMachines {
24
+ enrolled: boolean;
25
+ entitlements: Entitlements | null;
26
+ fetchedAt: number;
27
+ error: string | null;
28
+ /** Set when the service hasn't answered for a day: since when. */
29
+ uncheckedSince: number | null;
30
+ machines: AccountMachine[];
31
+ }
32
+
33
+ // The service shows a machine online for three missed beats.
34
+ const ONLINE_MS = 3 * 60_000;
35
+
36
+ export async function accountMachines(
37
+ refresh = false,
38
+ now = Date.now()
39
+ ): Promise<AccountMachines> {
40
+ const view = await accountView(refresh ? 0 : undefined);
41
+ const pins = new Map(
42
+ (
43
+ getDb()
44
+ .prepare(
45
+ `SELECT p.machine_id, p.host_id, p.state, p.suspended_reason
46
+ FROM peer_links p
47
+ JOIN hosts h ON h.id = p.host_id WHERE h.kind = 'connect'`
48
+ )
49
+ .all() as {
50
+ machine_id: string;
51
+ host_id: string;
52
+ state: string;
53
+ suspended_reason: string | null;
54
+ }[]
55
+ ).map((r) => [r.machine_id, r])
56
+ );
57
+ return {
58
+ enrolled: view.enrolled,
59
+ entitlements: view.entitlements,
60
+ fetchedAt: view.fetchedAt,
61
+ error: view.error,
62
+ uncheckedSince: view.uncheckedSince,
63
+ machines: view.peers.map((p) => {
64
+ const pin = pins.get(p.id);
65
+ const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
66
+ return {
67
+ id: p.id,
68
+ name: p.name,
69
+ hostname: p.hostname,
70
+ online: Number.isFinite(seen) && now - seen < ONLINE_MS,
71
+ lastSeenAt: p.lastSeenAt,
72
+ version: p.version,
73
+ hostId: pin?.host_id ?? null,
74
+ linkState: pin
75
+ ? pin.state === "active"
76
+ ? "active"
77
+ : "suspended"
78
+ : null,
79
+ suspendedReason:
80
+ pin && pin.state !== "active" ? pin.suspended_reason : null,
81
+ };
82
+ }),
83
+ };
84
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A machine's name as the Connect service tells it, made safe to show next
3
+ * to a pairing code: plain text on one line, no direction overrides, short,
4
+ * and with no digits at all once it holds 4 or more, so a lying service
5
+ * can't name a machine "type 482, 913" and have the person type that
6
+ * instead. Separators can't be enumerated ("482 and 913"), so the count is
7
+ * over the whole name.
8
+ */
9
+
10
+ export const NAME_MAX = 48;
11
+
12
+ // C0/C1 controls, zero-width and joiner characters, bidi embeddings,
13
+ // overrides and isolates, the byte-order mark.
14
+ const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
15
+ // Digits, any script.
16
+ const DIGIT = /\p{Nd}/gu;
17
+ const MAX_DIGITS = 3;
18
+
19
+ export const UNNAMED = "Unnamed machine";
20
+
21
+ export function cleanMachineName(raw: unknown): string {
22
+ if (typeof raw !== "string") return UNNAMED;
23
+ let s = raw
24
+ .normalize("NFKC")
25
+ .replace(/\s+/g, " ")
26
+ .replace(INVISIBLE, "")
27
+ .trim();
28
+ if ((s.match(DIGIT) ?? []).length > MAX_DIGITS) s = s.replace(DIGIT, "#");
29
+ if ([...s].length > NAME_MAX)
30
+ s = `${[...s].slice(0, NAME_MAX - 1).join("")}…`;
31
+ return s || UNNAMED;
32
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What pairing tells the person: a request waiting for its code here, and a
3
+ * machine linked. Pushed to paired phones (or Telegram); never throws.
4
+ */
5
+
6
+ import { notifyPhone } from "../notify";
7
+
8
+ const short = (name: string) => name.replace(/\s+/g, " ").slice(0, 60);
9
+
10
+ export function notifyLinkRequest(name: string) {
11
+ notifyPhone(
12
+ "peer-link",
13
+ `${short(name)} wants to link to this machine. Type the code it shows in Machines to allow it.`,
14
+ {
15
+ category: "system",
16
+ key: `peer-link:${Date.now()}`,
17
+ collapse: "peer-link",
18
+ }
19
+ );
20
+ }
21
+
22
+ export function notifyLinked(name: string) {
23
+ notifyPhone(
24
+ "peer-link",
25
+ `New machine linked: ${short(name)}. It can start agents and run code here; unlink it in Machines.`,
26
+ {
27
+ category: "system",
28
+ key: `peer-linked:${Date.now()}`,
29
+ collapse: "peer-link",
30
+ }
31
+ );
32
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Pairing, on the machine that asks (A): send the hello to the machine the
3
+ * account lists, check its answer is signed by the key the account lists
4
+ * for it, reveal, show the code, then ask every couple of seconds until a
5
+ * person on the other machine has typed it. B then shows a second code, and
6
+ * only that code typed here pins B: the account list alone never does, so a
7
+ * control plane that lists a key of its own for B can't get itself pinned.
8
+ */
9
+
10
+ import { readCappedBytes } from "../hosts/read-capped-bytes";
11
+ import { peerHostAllowed } from "./client";
12
+ import { linkingAllowed, listedPeer, type AccountPeer } from "./account";
13
+ import {
14
+ HELLO_MAX_BYTES,
15
+ commitTo,
16
+ deriveCode,
17
+ parseAck,
18
+ parseAnswer,
19
+ randomNonce,
20
+ sameCode,
21
+ signHello,
22
+ signReveal,
23
+ signStatusAsk,
24
+ typedCode,
25
+ verifyAck,
26
+ verifyAnswer,
27
+ type LinkState,
28
+ } from "./hello";
29
+ import { currentIdentity, loadBoxKey, type Identity } from "./keys";
30
+ import { notifyLinked } from "./notify";
31
+ import { MAX_WRONG, REQUEST_TTL_MS } from "./pairing";
32
+ import { KEY_CHANGED, keysChanged, pinPeer } from "./pin";
33
+
34
+ /** `confirm`: B took the code and shows its own, to be typed here. */
35
+ export type OutState = LinkState | "confirm" | "failed";
36
+
37
+ export interface Outgoing {
38
+ id: string;
39
+ machineId: string;
40
+ name: string;
41
+ code: string;
42
+ state: OutState;
43
+ error: string | null;
44
+ hostId: string | null;
45
+ attemptsLeft: number;
46
+ startedAt: number;
47
+ expiresAt: number;
48
+ }
49
+
50
+ interface Live extends Outgoing {
51
+ peer: AccountPeer;
52
+ requestId: string;
53
+ bBox: string;
54
+ confirmCode: string;
55
+ timer: NodeJS.Timeout | null;
56
+ }
57
+
58
+ export type Post = (
59
+ hostname: string,
60
+ path: string,
61
+ body: object
62
+ ) => Promise<{ status: number; json: unknown }>;
63
+
64
+ export interface OutDeps {
65
+ identity: () => Identity | null;
66
+ boxPub: () => string;
67
+ listed: (machineId: string) => Promise<AccountPeer | null>;
68
+ allowed: () => Promise<boolean | null>;
69
+ post: Post;
70
+ pin: typeof pinPeer;
71
+ onLinked: (name: string) => void;
72
+ now: () => number;
73
+ pollMs: number;
74
+ }
75
+
76
+ /** POSTs JSON to the other machine's Connect name: no redirects, small answers. */
77
+ export const httpsPost: Post = async (hostname, path, body) => {
78
+ if (!peerHostAllowed(hostname))
79
+ throw new Error("its address isn't a Connect machine's");
80
+ const res = await fetch(`https://${hostname}${path}`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify(body),
84
+ redirect: "error",
85
+ signal: AbortSignal.timeout(15_000),
86
+ });
87
+ const raw = await readCappedBytes(res, HELLO_MAX_BYTES);
88
+ let json: unknown = null;
89
+ try {
90
+ json = JSON.parse(raw.toString("utf8"));
91
+ } catch {
92
+ // not JSON: only the status says anything
93
+ }
94
+ return { status: res.status, json };
95
+ };
96
+
97
+ let box: string | null = null;
98
+ export const realOutDeps = (): OutDeps => ({
99
+ identity: () => currentIdentity(),
100
+ boxPub: () => (box ??= loadBoxKey().publicRaw),
101
+ listed: listedPeer,
102
+ allowed: linkingAllowed,
103
+ post: httpsPost,
104
+ pin: pinPeer,
105
+ onLinked: notifyLinked,
106
+ now: Date.now,
107
+ pollMs: 2000,
108
+ });
109
+
110
+ const g = globalThis as unknown as { __agentosPairingOut?: Map<string, Live> };
111
+ const links = () => (g.__agentosPairingOut ??= new Map());
112
+
113
+ export const resetOutgoing = () => {
114
+ for (const l of links().values()) if (l.timer) clearTimeout(l.timer);
115
+ g.__agentosPairingOut = undefined;
116
+ };
117
+
118
+ const view = (l: Live): Outgoing => ({
119
+ id: l.id,
120
+ machineId: l.machineId,
121
+ name: l.name,
122
+ code: l.code,
123
+ state: l.state,
124
+ error: l.error,
125
+ hostId: l.hostId,
126
+ attemptsLeft: l.attemptsLeft,
127
+ startedAt: l.startedAt,
128
+ expiresAt: l.expiresAt,
129
+ });
130
+
131
+ export class LinkError extends Error {
132
+ constructor(
133
+ message: string,
134
+ readonly status = 400
135
+ ) {
136
+ super(message);
137
+ }
138
+ }
139
+
140
+ const theirError = (json: unknown, status: number) => {
141
+ const e = (json as { error?: unknown } | null)?.error;
142
+ return typeof e === "string"
143
+ ? e.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200)
144
+ : `HTTP ${status}`;
145
+ };
146
+
147
+ /** Starts a link to an account machine; the answer carries the code to show. */
148
+ export async function startLink(
149
+ machineId: string,
150
+ deps: OutDeps = realOutDeps()
151
+ ): Promise<Outgoing> {
152
+ const self = deps.identity();
153
+ if (!self)
154
+ throw new LinkError(
155
+ "This machine isn't connected to AgentOS Connect.",
156
+ 409
157
+ );
158
+ if ((await deps.allowed()) !== true)
159
+ throw new LinkError("Linking machines needs AgentOS Connect Pro.", 402);
160
+ const peer = await deps.listed(machineId);
161
+ if (!peer)
162
+ throw new LinkError("That machine isn't on your AgentOS account.", 404);
163
+ if (!peerHostAllowed(peer.hostname))
164
+ throw new LinkError("Its address isn't a Connect machine's.", 502);
165
+ if (keysChanged(peer.id, peer)) throw new LinkError(KEY_CHANGED, 409);
166
+ for (const l of links().values())
167
+ if (l.machineId === machineId && open(l))
168
+ stop(l, "declined", "replaced by a new request");
169
+
170
+ const aNonce = randomNonce(32);
171
+ const aBox = deps.boxPub();
172
+ const hello = signHello(self.signKey, {
173
+ from: self.machineId,
174
+ to: peer.id,
175
+ ts: deps.now(),
176
+ nonce: randomNonce(16),
177
+ sign_pub: self.signPub,
178
+ box_pub: aBox,
179
+ commit: commitTo(aBox, self.signPub, aNonce),
180
+ });
181
+ const send = async (path: string, body: object) => {
182
+ try {
183
+ return await deps.post(peer.hostname, path, body);
184
+ } catch (err) {
185
+ throw new LinkError(
186
+ `Can't reach ${peer.name}: ${err instanceof Error ? err.message : String(err)}`,
187
+ 502
188
+ );
189
+ }
190
+ };
191
+ const helloRes = await send("/api/peer/hello", hello);
192
+ if (helloRes.status !== 200)
193
+ throw new LinkError(
194
+ `${peer.name} said no: ${theirError(helloRes.json, helloRes.status)}`,
195
+ helloRes.status === 402 ? 402 : 502
196
+ );
197
+ const ack = parseAck(helloRes.json);
198
+ // Signed by the key the account lists for it, answering this hello.
199
+ if (!ack || !verifyAck(ack, hello, peer.signPub))
200
+ throw new LinkError(`${peer.name} answered with the wrong key.`, 502);
201
+ if (keysChanged(peer.id, { signPub: ack.sign_pub, boxPub: ack.box_pub }))
202
+ throw new LinkError(KEY_CHANGED, 409);
203
+
204
+ const reveal = signReveal(self.signKey, {
205
+ from: self.machineId,
206
+ to: peer.id,
207
+ ts: deps.now(),
208
+ nonce: randomNonce(16),
209
+ request_id: ack.request_id,
210
+ a_nonce: aNonce,
211
+ });
212
+ const revealRes = await send("/api/peer/hello/reveal", reveal);
213
+ const answer = parseAnswer(revealRes.json);
214
+ if (
215
+ revealRes.status !== 200 ||
216
+ !answer ||
217
+ !verifyAnswer(answer, reveal, peer.signPub) ||
218
+ answer.state !== "pending"
219
+ )
220
+ throw new LinkError(
221
+ `${peer.name} didn't take the request: ${theirError(revealRes.json, revealRes.status)}`,
222
+ 502
223
+ );
224
+
225
+ const now = deps.now();
226
+ const transcript = {
227
+ from: self.machineId,
228
+ to: peer.id,
229
+ aSign: self.signPub,
230
+ aBox,
231
+ bSign: ack.sign_pub,
232
+ bBox: ack.box_pub,
233
+ aNonce,
234
+ bNonce: ack.b_nonce,
235
+ };
236
+ const live: Live = {
237
+ id: randomNonce(16),
238
+ machineId: peer.id,
239
+ name: peer.name,
240
+ code: deriveCode(transcript),
241
+ state: "pending",
242
+ error: null,
243
+ hostId: null,
244
+ attemptsLeft: MAX_WRONG,
245
+ startedAt: now,
246
+ expiresAt: now + REQUEST_TTL_MS,
247
+ peer,
248
+ requestId: ack.request_id,
249
+ bBox: ack.box_pub,
250
+ confirmCode: deriveCode(transcript, "confirm"),
251
+ timer: null,
252
+ };
253
+ links().set(live.id, live);
254
+ schedule(live, self, deps);
255
+ return view(live);
256
+ }
257
+
258
+ const open = (l: Live) => l.state === "pending" || l.state === "confirm";
259
+
260
+ function stop(l: Live, state: OutState, error: string | null = null) {
261
+ if (l.timer) clearTimeout(l.timer);
262
+ l.timer = null;
263
+ l.state = state;
264
+ l.error = error;
265
+ }
266
+
267
+ function schedule(l: Live, self: Identity, deps: OutDeps) {
268
+ l.timer = setTimeout(() => void poll(l, self, deps), deps.pollMs);
269
+ l.timer.unref?.();
270
+ }
271
+
272
+ /** One status call; exported for tests that drive the clock. */
273
+ export async function poll(l: Live, self: Identity, deps: OutDeps) {
274
+ l.timer = null;
275
+ if (l.state !== "pending") return;
276
+ if (deps.now() > l.expiresAt) return stop(l, "expired");
277
+ const ask = signStatusAsk(self.signKey, {
278
+ from: self.machineId,
279
+ to: l.machineId,
280
+ ts: deps.now(),
281
+ nonce: randomNonce(16),
282
+ request_id: l.requestId,
283
+ });
284
+ let answer = null;
285
+ try {
286
+ const res = await deps.post(l.peer.hostname, "/api/peer/hello/status", ask);
287
+ if (res.status === 404) return stop(l, "expired");
288
+ const a = parseAnswer(res.json);
289
+ if (res.status === 200 && a && verifyAnswer(a, ask, l.peer.signPub))
290
+ answer = a;
291
+ else l.error = `unexpected answer (HTTP ${res.status})`;
292
+ } catch (err) {
293
+ // Offline for a moment: keep asking until the request expires.
294
+ l.error = err instanceof Error ? err.message : String(err);
295
+ }
296
+ if (l.state !== "pending") return;
297
+ if (answer?.state === "accepted") {
298
+ // Nothing is pinned yet: B's say-so is only as good as its key, and the
299
+ // key came from the account list. The person types B's code here next.
300
+ stop(l, "confirm");
301
+ l.expiresAt = deps.now() + REQUEST_TTL_MS;
302
+ return;
303
+ }
304
+ if (answer && answer.state !== "pending") return stop(l, answer.state);
305
+ if (answer) l.error = null;
306
+ schedule(l, self, deps);
307
+ }
308
+
309
+ export function outgoingLink(id: string): Outgoing | null {
310
+ const l = links().get(id);
311
+ return l ? view(l) : null;
312
+ }
313
+
314
+ export type ConfirmResult =
315
+ | { ok: true; link: Outgoing }
316
+ | { ok: false; status: number; error: string; attemptsLeft?: number };
317
+
318
+ /** The code the other machine shows once it took ours, typed here. */
319
+ export function confirmLink(
320
+ id: string,
321
+ input: unknown,
322
+ deps: OutDeps = realOutDeps()
323
+ ): ConfirmResult {
324
+ const l = links().get(id);
325
+ if (l?.state === "confirm" && deps.now() > l.expiresAt) stop(l, "expired");
326
+ if (!l || l.state !== "confirm")
327
+ return {
328
+ ok: false,
329
+ status: 404,
330
+ error: "That request has ended. Start again.",
331
+ };
332
+ const code = typedCode(input);
333
+ if (!code)
334
+ return {
335
+ ok: false,
336
+ status: 400,
337
+ error: `Type the 6 digits ${l.name} shows.`,
338
+ };
339
+ if (!sameCode(code, l.confirmCode)) {
340
+ l.attemptsLeft -= 1;
341
+ if (l.attemptsLeft <= 0) {
342
+ stop(l, "failed", "wrong code 3 times");
343
+ return {
344
+ ok: false,
345
+ status: 403,
346
+ error: "Wrong code 3 times: nothing was linked here. Start again.",
347
+ attemptsLeft: 0,
348
+ };
349
+ }
350
+ return {
351
+ ok: false,
352
+ status: 403,
353
+ error: "That code doesn't match.",
354
+ attemptsLeft: l.attemptsLeft,
355
+ };
356
+ }
357
+ try {
358
+ l.hostId = deps.pin({
359
+ id: l.peer.id,
360
+ name: l.peer.name,
361
+ hostname: l.peer.hostname,
362
+ signPub: l.peer.signPub,
363
+ boxPub: l.bBox,
364
+ });
365
+ } catch (err) {
366
+ stop(l, "failed", err instanceof Error ? err.message : String(err));
367
+ return { ok: false, status: 409, error: l.error! };
368
+ }
369
+ stop(l, "accepted");
370
+ deps.onLinked(l.name);
371
+ return { ok: true, link: view(l) };
372
+ }
373
+
374
+ export function cancelLink(id: string): boolean {
375
+ const l = links().get(id);
376
+ if (!l || !open(l)) return false;
377
+ stop(l, "declined", "cancelled here");
378
+ return true;
379
+ }
380
+
381
+ /** Tests: the live record behind a view, to drive poll() by hand. */
382
+ export const _live = (id: string) => links().get(id) ?? null;