@saadnvd1/agent-os 0.3.700 → 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 (185) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/spawn/route.ts +12 -1
  4. package/app/api/claims/[id]/route.ts +20 -0
  5. package/app/api/claims/check/route.ts +23 -0
  6. package/app/api/claims/check.test.ts +113 -0
  7. package/app/api/claims/exchange/route.ts +42 -0
  8. package/app/api/claims/outside.test.ts +90 -0
  9. package/app/api/claims/route.test.ts +193 -0
  10. package/app/api/claims/route.ts +73 -0
  11. package/app/api/hosts/[id]/route.ts +9 -1
  12. package/app/api/peer/unlink/route.ts +17 -0
  13. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  14. package/app/api/peers/[hostId]/route.ts +20 -0
  15. package/app/api/peers/gate.test.ts +64 -1
  16. package/app/api/peers/pending/route.test.ts +37 -0
  17. package/app/api/peers/pending/route.ts +11 -4
  18. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  19. package/app/api/session-groups/[id]/route.ts +25 -0
  20. package/app/api/session-groups/order/route.ts +15 -0
  21. package/app/api/session-groups/route.ts +21 -0
  22. package/app/api/sessions/[id]/fork/route.ts +3 -0
  23. package/app/api/sessions/[id]/group/route.ts +18 -0
  24. package/app/api/sessions/[id]/route.ts +14 -4
  25. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  26. package/app/api/sessions/claims.test.ts +122 -0
  27. package/app/api/sessions/group/route.ts +17 -0
  28. package/app/api/sessions/route.ts +15 -14
  29. package/app/page.tsx +5 -0
  30. package/components/Claims/ClaimFiles.tsx +38 -0
  31. package/components/Claims/ClaimRow.tsx +82 -0
  32. package/components/Claims/ClaimsSheet.tsx +176 -0
  33. package/components/Claims/OverlapPill.tsx +31 -0
  34. package/components/Claims/StartClaimsNotice.tsx +70 -0
  35. package/components/Claims/claims-ui.test.ts +161 -0
  36. package/components/Claims/format.ts +57 -0
  37. package/components/Claims/index.ts +3 -0
  38. package/components/Draft/DraftPanel.tsx +40 -2
  39. package/components/Hosts/AccountMachines.tsx +32 -18
  40. package/components/Hosts/HostRow.tsx +155 -14
  41. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  42. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  43. package/components/SessionList/GroupShelves.tsx +195 -0
  44. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  45. package/components/SessionList/RowSubtitle.tsx +7 -1
  46. package/components/SessionList/SelectionToolbar.tsx +52 -1
  47. package/components/SessionList/SessionRow.tsx +1 -0
  48. package/components/SessionList/SessionRowMenu.tsx +17 -0
  49. package/components/SessionList/SessionShelves.tsx +5 -0
  50. package/components/SessionList/SidebarBody.tsx +6 -0
  51. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  52. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  53. package/components/SessionList/SidebarHeader.tsx +5 -0
  54. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  55. package/components/SessionList/index.tsx +18 -14
  56. package/components/SessionList/useSidebarData.ts +10 -0
  57. package/components/ui/notice.tsx +46 -0
  58. package/components/ui/status-pill.tsx +39 -0
  59. package/data/claims/index.ts +2 -0
  60. package/data/claims/keys.ts +5 -0
  61. package/data/claims/queries.ts +47 -0
  62. package/data/peers/keys.ts +1 -0
  63. package/data/peers/queries.ts +31 -0
  64. package/data/push/topics.ts +4 -0
  65. package/data/session-groups/index.ts +156 -0
  66. package/data/sessions/index.ts +0 -1
  67. package/data/sessions/pin.ts +2 -6
  68. package/data/sessions/queries.ts +10 -29
  69. package/data/sessions/shelf.ts +2 -6
  70. package/data/tasks/index.ts +2 -0
  71. package/hooks/useSessions.ts +0 -12
  72. package/lib/__fixtures__/peer-rpc.ts +65 -33
  73. package/lib/agents/spawn.ts +4 -0
  74. package/lib/bus/index.ts +8 -0
  75. package/lib/claims/collectLocal.ts +121 -0
  76. package/lib/claims/describe.ts +41 -0
  77. package/lib/claims/exchange.ts +91 -0
  78. package/lib/claims/goal.ts +45 -0
  79. package/lib/claims/index.ts +10 -0
  80. package/lib/claims/loop.ts +51 -0
  81. package/lib/claims/machine.ts +21 -0
  82. package/lib/claims/mine.ts +43 -0
  83. package/lib/claims/notifyOverlaps.ts +101 -0
  84. package/lib/claims/onMerged.ts +85 -0
  85. package/lib/claims/outside.ts +36 -0
  86. package/lib/claims/overlap.ts +87 -0
  87. package/lib/claims/overlapCache.ts +86 -0
  88. package/lib/claims/overlapText.ts +36 -0
  89. package/lib/claims/peers.ts +56 -0
  90. package/lib/claims/startCheck.ts +100 -0
  91. package/lib/claims/startWarning.ts +29 -0
  92. package/lib/claims/store.ts +366 -0
  93. package/lib/claims/types.ts +102 -0
  94. package/lib/claims/view.ts +80 -0
  95. package/lib/claims/yield.ts +24 -0
  96. package/lib/db/changes.ts +6 -0
  97. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  98. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  99. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  100. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  101. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  102. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  103. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  104. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  105. package/lib/db/queries.ts +0 -40
  106. package/lib/db/session-parts/columns.ts +1 -0
  107. package/lib/db/types.ts +16 -8
  108. package/lib/doctor/checks.ts +64 -1
  109. package/lib/git-remote.ts +43 -0
  110. package/lib/hosts/index.ts +10 -1
  111. package/lib/orchestration.ts +2 -0
  112. package/lib/orchestrator/act.ts +54 -4
  113. package/lib/orchestrator/brief.ts +2 -1
  114. package/lib/orchestrator/claim-wait.ts +71 -0
  115. package/lib/orchestrator/conditions.ts +28 -1
  116. package/lib/orchestrator/describe.ts +6 -1
  117. package/lib/orchestrator/facts.ts +5 -0
  118. package/lib/orchestrator/serve.ts +37 -2
  119. package/lib/orchestrator/tool-names.ts +1 -0
  120. package/lib/orchestrator/tool-schemas.ts +31 -1
  121. package/lib/orchestrator/tools.ts +3 -1
  122. package/lib/orchestrator/watcher.ts +19 -1
  123. package/lib/peer/account.ts +74 -2
  124. package/lib/peer/allowlist.ts +1 -1
  125. package/lib/peer/client.ts +26 -1
  126. package/lib/peer/link-cli.ts +19 -4
  127. package/lib/peer/links.ts +117 -5
  128. package/lib/peer/machines.ts +14 -2
  129. package/lib/peer/name.ts +32 -0
  130. package/lib/peer/pairing.ts +34 -5
  131. package/lib/peer/pin.ts +10 -3
  132. package/lib/peer/reasons.ts +7 -0
  133. package/lib/peer/revoke.ts +75 -0
  134. package/lib/peer/rpc-server.ts +53 -5
  135. package/lib/peer/unlink.ts +62 -0
  136. package/lib/peer/unlinked-proof.ts +44 -0
  137. package/lib/projects.ts +5 -2
  138. package/lib/security/demo.ts +7 -0
  139. package/lib/session-groups/assignSessions.ts +81 -0
  140. package/lib/session-groups/cli.ts +61 -0
  141. package/lib/session-groups/createGroup.ts +28 -0
  142. package/lib/session-groups/index.ts +21 -0
  143. package/lib/session-groups/removeGroup.ts +36 -0
  144. package/lib/session-groups/reorderGroups.ts +31 -0
  145. package/lib/session-groups/resolveGroup.ts +19 -0
  146. package/lib/session-groups/route.ts +32 -0
  147. package/lib/session-groups/store.ts +85 -0
  148. package/lib/session-groups/types.ts +25 -0
  149. package/lib/session-groups/updateGroup.ts +35 -0
  150. package/lib/session-groups/workspace.ts +21 -0
  151. package/lib/sessions/launch.ts +5 -2
  152. package/lib/sidebar/rollup.ts +1 -0
  153. package/lib/sidebar/row-menu.ts +2 -0
  154. package/lib/sidebar/shelves.ts +90 -15
  155. package/lib/sidebar/visible-order.ts +5 -0
  156. package/lib/tasks/hold-checks.ts +3 -1
  157. package/lib/tasks/holds.ts +3 -0
  158. package/lib/tasks/index.ts +5 -0
  159. package/lib/tasks/queue-rows.ts +8 -1
  160. package/lib/tasks/queue-start.ts +1 -0
  161. package/lib/tasks/queue-types.ts +9 -1
  162. package/lib/tasks/queue.ts +15 -5
  163. package/lib/tasks/schedule-types.ts +1 -0
  164. package/lib/test-temp.ts +19 -3
  165. package/lib/workspaces.ts +11 -3
  166. package/lib/worktree-sweep/testTemp.ts +23 -106
  167. package/package.json +1 -1
  168. package/scripts/agent-os +1 -1
  169. package/scripts/autodeploy +2 -0
  170. package/scripts/autodeploy.test.ts +31 -1
  171. package/scripts/claim-hook.sh +31 -0
  172. package/scripts/claim-hook.test.ts +230 -0
  173. package/scripts/lib/cli-links.sh +36 -0
  174. package/scripts/lib/commands.sh +3 -0
  175. package/scripts/lib/machine.sh +27 -2
  176. package/scripts/lib/machine.test.ts +25 -3
  177. package/scripts/sync-cli-links +7 -0
  178. package/scripts/sync-cli-links.test.ts +69 -0
  179. package/server.ts +3 -0
  180. package/stores/claimsUi.ts +22 -0
  181. package/app/api/groups/[...path]/route.ts +0 -136
  182. package/app/api/groups/route.ts +0 -93
  183. package/data/groups/index.ts +0 -1
  184. package/data/groups/mutations.ts +0 -95
  185. 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;
@@ -17,6 +17,7 @@ export interface AccountMachine {
17
17
  /** The Machines host it's linked as, when it is. */
18
18
  hostId: string | null;
19
19
  linkState: "active" | "suspended" | null;
20
+ suspendedReason: string | null;
20
21
  }
21
22
 
22
23
  export interface AccountMachines {
@@ -24,6 +25,8 @@ export interface AccountMachines {
24
25
  entitlements: Entitlements | null;
25
26
  fetchedAt: number;
26
27
  error: string | null;
28
+ /** Set when the service hasn't answered for a day: since when. */
29
+ uncheckedSince: number | null;
27
30
  machines: AccountMachine[];
28
31
  }
29
32
 
@@ -39,10 +42,16 @@ export async function accountMachines(
39
42
  (
40
43
  getDb()
41
44
  .prepare(
42
- `SELECT p.machine_id, p.host_id, p.state FROM peer_links p
45
+ `SELECT p.machine_id, p.host_id, p.state, p.suspended_reason
46
+ FROM peer_links p
43
47
  JOIN hosts h ON h.id = p.host_id WHERE h.kind = 'connect'`
44
48
  )
45
- .all() as { machine_id: string; host_id: string; state: string }[]
49
+ .all() as {
50
+ machine_id: string;
51
+ host_id: string;
52
+ state: string;
53
+ suspended_reason: string | null;
54
+ }[]
46
55
  ).map((r) => [r.machine_id, r])
47
56
  );
48
57
  return {
@@ -50,6 +59,7 @@ export async function accountMachines(
50
59
  entitlements: view.entitlements,
51
60
  fetchedAt: view.fetchedAt,
52
61
  error: view.error,
62
+ uncheckedSince: view.uncheckedSince,
53
63
  machines: view.peers.map((p) => {
54
64
  const pin = pins.get(p.id);
55
65
  const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
@@ -66,6 +76,8 @@ export async function accountMachines(
66
76
  ? "active"
67
77
  : "suspended"
68
78
  : null,
79
+ suspendedReason:
80
+ pin && pin.state !== "active" ? pin.suspended_reason : null,
69
81
  };
70
82
  }),
71
83
  };
@@ -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
+ }
@@ -159,7 +159,10 @@ export async function receiveHello(
159
159
  s.hellos.length >= UNPROVEN
160
160
  )
161
161
  return no(429, "too many requests");
162
- s.hellos.push({ at: now, from: null, source });
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);
163
166
  const allowed = await deps.allowed();
164
167
  if (allowed !== true)
165
168
  return no(402, "Linking machines needs AgentOS Connect Pro");
@@ -179,7 +182,7 @@ export async function receiveHello(
179
182
  proven.filter((h) => h.from === hello.from).length >= PER_SENDER
180
183
  )
181
184
  return no(429, "too many link requests; wait a few minutes");
182
- s.hellos[s.hellos.length - 1].from = hello.from;
185
+ entry.from = hello.from;
183
186
  for (const old of s.requests.values())
184
187
  if (old.peer.id === hello.from && open(old)) {
185
188
  old.state = "declined";
@@ -309,14 +312,28 @@ export interface PendingView {
309
312
  lastSeenAt: string | null;
310
313
  expiresAt: number;
311
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;
312
319
  }
313
320
 
314
- /** Requests waiting for a person here to type the code. */
315
- export function pendingRequests(now = Date.now()): PendingView[] {
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[] {
316
329
  const s = store();
317
330
  sweep(s, now);
318
331
  return [...s.requests.values()]
319
- .filter((r) => r.state === "pending")
332
+ .filter(
333
+ (r) =>
334
+ r.state === "pending" ||
335
+ (r.state === "accepted" && now - r.createdAt <= REQUEST_TTL_MS)
336
+ )
320
337
  .map((r) => ({
321
338
  id: r.id,
322
339
  machineId: r.peer.id,
@@ -324,6 +341,10 @@ export function pendingRequests(now = Date.now()): PendingView[] {
324
341
  lastSeenAt: r.peer.lastSeenAt,
325
342
  expiresAt: r.createdAt + REQUEST_TTL_MS,
326
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 }),
327
348
  }));
328
349
  }
329
350
 
@@ -379,6 +400,14 @@ export async function acceptRequest(
379
400
  status: 402,
380
401
  error: "Linking machines needs AgentOS Connect Pro.",
381
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
+ };
382
411
  let hostId: string;
383
412
  try {
384
413
  hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
package/lib/peer/pin.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * of a pin: a machine whose keys changed is unlinked first, by a person.
5
5
  */
6
6
 
7
- import { createConnectHost, listRemoteHosts } from "../hosts";
8
- import { pinForMachine, savePin } from "./links";
7
+ import { createConnectHost, getHost, listRemoteHosts } from "../hosts";
8
+ import { pinForMachine, savePin, unlinkedMachine } from "./links";
9
+ import { cleanMachineName } from "./name";
9
10
 
10
11
  export interface Paired {
11
12
  id: string;
@@ -43,7 +44,13 @@ export function keysChanged(
43
44
  export function pinPeer(p: Paired): string {
44
45
  const pinned = pinForMachine(p.id);
45
46
  if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
46
- const hostId = pinned?.hostId ?? createConnectHost(freeName(p.name)).id;
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;
47
54
  savePin({
48
55
  hostId,
49
56
  machineId: p.id,
@@ -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
+ }
@@ -15,7 +15,10 @@
15
15
  * Refusals before decryption are plain and fixed (`bad envelope`, `bad
16
16
  * signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
17
17
  * and a bad signature all read `bad signature`, so a stranger can't tell
18
- * which machines are linked here; the audit keeps the difference. Everything
18
+ * which machines are linked here; the audit keeps the difference. The one
19
+ * exception is a machine unlinked here, proven by the key it was pinned
20
+ * with: it gets 403 `unknown peer`, signed (unlinked-proof.ts), so it can
21
+ * show that it was unlinked. Everything
19
22
  * after is inside the envelope,
20
23
  * always HTTP 200, so whoever carries the traffic never learns the route or
21
24
  * its status.
@@ -45,7 +48,14 @@ import {
45
48
  type BoxKey,
46
49
  type Identity,
47
50
  } from "./keys";
48
- import { hasActivePin, pinForMachine, type PeerPin } from "./links";
51
+ import {
52
+ hasActivePin,
53
+ hasRecentUnlinked,
54
+ pinForMachine,
55
+ unlinkedMachine,
56
+ type PeerPin,
57
+ } from "./links";
58
+ import { UNLINKED_PROOF_HEADER, signUnlinked } from "./unlinked-proof";
49
59
  import { sqliteNonceStore } from "./nonce-store";
50
60
  import { createReplayGuard, type ReplayGuard } from "./replay";
51
61
  import {
@@ -75,7 +85,11 @@ export interface RpcDeps {
75
85
  identity: () => Identity | null;
76
86
  boxKey: () => BoxKey;
77
87
  pinForMachine: (machineId: string) => PeerPin | null;
78
- /** False while no link is active: the route is closed to everyone. */
88
+ /** A machine unlinked here lately: the key it was pinned with. */
89
+ unlinked?: (
90
+ machineId: string
91
+ ) => { signPub: string; hostId: string | null } | null;
92
+ /** False while no link is active or recently unlinked: the route is closed to everyone. */
79
93
  anyActive: () => boolean;
80
94
  replay: ReplayGuard;
81
95
  dispatch: (d: Dispatch) => Promise<PeerResponse>;
@@ -125,6 +139,10 @@ export async function handlePeerRpc(
125
139
  const self = deps.identity();
126
140
  let fromMachine: string | null = null;
127
141
  let pin: PeerPin | null = null;
142
+ // An unlinked sender whose header its old key proved, and its nonce.
143
+ let unlinkedHost: string | null = null;
144
+ let viaUnlinked = false;
145
+ let provenUnlinked: string | null = null;
128
146
  const audit = (
129
147
  outcome: Outcome,
130
148
  status: number,
@@ -133,7 +151,7 @@ export async function handlePeerRpc(
133
151
  bytes = 0
134
152
  ) =>
135
153
  deps.audit({
136
- hostId: pin?.hostId ?? null,
154
+ hostId: pin?.hostId ?? unlinkedHost,
137
155
  machineId: fromMachine,
138
156
  direction: "in",
139
157
  method,
@@ -144,6 +162,19 @@ export async function handlePeerRpc(
144
162
  outcome,
145
163
  });
146
164
  const refuse = (refusal: Refusal): null => {
165
+ if (refusal === "unknown_peer" && provenUnlinked && self && fromMachine) {
166
+ audit("unknown_peer", 403);
167
+ res.setHeader(
168
+ UNLINKED_PROOF_HEADER,
169
+ signUnlinked(self.signKey, {
170
+ from: self.machineId,
171
+ to: fromMachine,
172
+ nonce: provenUnlinked,
173
+ })
174
+ );
175
+ plain(res, 403, "unknown peer");
176
+ return null;
177
+ }
147
178
  const [status, text, outcome] = REFUSED[refusal];
148
179
  audit(outcome, status);
149
180
  plain(res, status, text);
@@ -161,6 +192,18 @@ export async function handlePeerRpc(
161
192
  : found.state !== "active"
162
193
  ? "suspended"
163
194
  : null;
195
+ if (!found) {
196
+ const gone = deps.unlinked?.(machineId);
197
+ if (gone)
198
+ try {
199
+ const key = signPublic(gone.signPub);
200
+ unlinkedHost = gone.hostId;
201
+ viaUnlinked = true;
202
+ return key;
203
+ } catch {
204
+ // Unreadable: a decoy, like any stranger.
205
+ }
206
+ }
164
207
  if (!found || hidden) return DECOY_KEY;
165
208
  try {
166
209
  return signPublic(found.signPub);
@@ -210,6 +253,10 @@ export async function handlePeerRpc(
210
253
  now
211
254
  );
212
255
  if (fresh !== "ok") return { refusal: fresh };
256
+ if (viaUnlinked) {
257
+ provenUnlinked = header.nonce;
258
+ return { refusal: "unknown_peer" };
259
+ }
213
260
  claimed = `${header.from}:${header.nonce}`;
214
261
  return { peer: header.from };
215
262
  },
@@ -360,7 +407,8 @@ export function rpcDeps(port: number): RpcDeps {
360
407
  identity: () => currentIdentity(),
361
408
  boxKey: () => (box ??= loadBoxKey()),
362
409
  pinForMachine,
363
- anyActive: hasActivePin,
410
+ unlinked: (machineId) => unlinkedMachine(machineId),
411
+ anyActive: () => hasActivePin() || hasRecentUnlinked(),
364
412
  replay: createReplayGuard({ store: sqliteNonceStore() }),
365
413
  dispatch: loopbackDispatch(port),
366
414
  audit: recordPeerCall,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Unlinking a machine linked through Connect, from either side. Here: the
3
+ * pin goes at once (links.ts dropPin), then the other machine is told with a
4
+ * signed POST /api/peer/unlink, best effort. There: the same call drops its
5
+ * pin and its Machines says "Unlinked by <this machine>". If that call never
6
+ * arrives, the other machine's next call here is answered with a signed
7
+ * "unknown peer" (rpc-server.ts), which does the same.
8
+ */
9
+
10
+ import { deleteHost } from "../hosts";
11
+ import { hostLink, type HostLink } from "../hosts/remote-api";
12
+ import { peerCall } from "./client";
13
+ import { dropPin, pinForHost } from "./links";
14
+
15
+ export const UNLINKED_HERE = "Unlinked here";
16
+
17
+ export interface UnlinkDeps {
18
+ tell: (link: HostLink) => Promise<unknown>;
19
+ remove: (hostId: string) => void;
20
+ }
21
+
22
+ const realDeps: UnlinkDeps = {
23
+ tell: (link) =>
24
+ peerCall(link, "/api/peer/unlink", { body: {}, timeout: 8_000 }),
25
+ remove: deleteHost,
26
+ };
27
+
28
+ export interface UnlinkResult {
29
+ name: string;
30
+ /** The other machine confirmed it dropped its side. */
31
+ told: boolean;
32
+ /** The machine left Machines here too (it held no sessions or projects). */
33
+ removed: boolean;
34
+ }
35
+
36
+ /** A person here unlinks it. Throws when it isn't linked through Connect. */
37
+ export async function unlinkPeer(
38
+ hostId: string,
39
+ deps: UnlinkDeps = realDeps
40
+ ): Promise<UnlinkResult> {
41
+ const link = hostLink(hostId);
42
+ if (!link?.connect || !pinForHost(hostId))
43
+ throw new Error("That machine isn't linked through Connect.");
44
+ dropPin(hostId, { byPeer: false, reason: UNLINKED_HERE });
45
+ let told = false;
46
+ // A suspended link is refused there too; nothing would arrive.
47
+ if (link.connect.state === "active")
48
+ try {
49
+ await deps.tell(link);
50
+ told = true;
51
+ } catch {
52
+ // Best effort: its next call here is refused and says why.
53
+ }
54
+ let removed = false;
55
+ try {
56
+ deps.remove(hostId);
57
+ removed = true;
58
+ } catch {
59
+ // It still holds sessions or projects; it stays, unlinked.
60
+ }
61
+ return { name: link.hostName, told, removed };
62
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one refusal that is signed: "unknown peer", sent to a machine that was
3
+ * unlinked here, bound to its own request's nonce. Only the key it had pinned
4
+ * for this machine verifies it, so whoever carries the traffic can't forge an
5
+ * unlink, and a replayed one never matches a new request.
6
+ */
7
+
8
+ import crypto, { type KeyObject } from "crypto";
9
+
10
+ export const UNLINKED_PROOF_HEADER = "x-agentos-peer-unlinked";
11
+ const DOMAIN = "agentos-peer-unlinked-v1";
12
+
13
+ interface Bound {
14
+ /** The machine refusing: the one that unlinked. */
15
+ from: string;
16
+ /** The caller, which it no longer knows. */
17
+ to: string;
18
+ /** The caller's request nonce, base64url. */
19
+ nonce: string;
20
+ }
21
+
22
+ const message = (o: Bound) =>
23
+ Buffer.from(`${DOMAIN}|${o.from}|${o.to}|${o.nonce}`);
24
+
25
+ export const signUnlinked = (signKey: KeyObject, o: Bound): string =>
26
+ crypto.sign(null, message(o), signKey).toString("base64url");
27
+
28
+ export function verifyUnlinked(
29
+ signPub: KeyObject,
30
+ o: Bound,
31
+ sig: string | null
32
+ ): boolean {
33
+ if (!sig || !/^[A-Za-z0-9_-]{86}$/.test(sig)) return false;
34
+ try {
35
+ return crypto.verify(
36
+ null,
37
+ message(o),
38
+ signPub,
39
+ Buffer.from(sig, "base64url")
40
+ );
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
package/lib/projects.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type DevServerType,
20
20
  } from "./db";
21
21
  import { resolveModelForAgent } from "./model-catalog";
22
+ import { clearForeignGroup } from "./session-groups/assignSessions";
22
23
  import type { AgentType } from "./providers";
23
24
 
24
25
  const execAsync = promisify(exec);
@@ -312,7 +313,7 @@ export function getProjectSessions(projectId: string): Session[] {
312
313
  export function moveSessionToProject(
313
314
  sessionId: string,
314
315
  projectId: string
315
- ): void {
316
+ ): { clearedGroup: string | null } {
316
317
  const to = getProject(projectId);
317
318
  const from = db
318
319
  .prepare(
@@ -321,7 +322,7 @@ export function moveSessionToProject(
321
322
  )
322
323
  .get(sessionId) as
323
324
  { workspace_id: string | null; own: string | null } | undefined;
324
- db.transaction(() => {
325
+ return db.transaction(() => {
325
326
  queries.updateSessionProject(db).run(projectId, sessionId);
326
327
  // A project's session follows the project; one left in none keeps the
327
328
  // workspace it was in.
@@ -333,6 +334,8 @@ export function moveSessionToProject(
333
334
  : (from?.workspace_id ?? from?.own ?? null),
334
335
  sessionId
335
336
  );
337
+ // A group belongs to the workspace the session just left.
338
+ return { clearedGroup: clearForeignGroup(sessionId) };
336
339
  })();
337
340
  }
338
341