@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
@@ -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);
@@ -177,6 +220,13 @@ export async function handlePeerRpc(
177
220
  return null;
178
221
  };
179
222
 
223
+ // The one nonce the header claimed: the whole envelope must carry it.
224
+ let claimed: string | null = null;
225
+ const spent: ReplayGuard = {
226
+ check: (peer, nonce) =>
227
+ claimed !== null && claimed === `${peer}:${nonce}` ? "ok" : "replay",
228
+ };
229
+
180
230
  if (!deps.anyActive()) return plain(res, 404, "not found");
181
231
  const ticket = deps.budget.open(sourceOf(req));
182
232
  if (!ticket) return void refuse("busy");
@@ -193,6 +243,21 @@ export async function handlePeerRpc(
193
243
  return { refusal: "bad_sig" };
194
244
  const now = deps.now?.() ?? Date.now();
195
245
  if (Math.abs(now - header.ts) > SKEW_MS) return { refusal: "stale" };
246
+ // The nonce is spent the moment its header is proven, so a header
247
+ // seen on the wire can't be sent again with junk bodies to hold
248
+ // the sender's share while the first is still arriving.
249
+ const fresh = deps.replay.check(
250
+ header.from,
251
+ header.nonce,
252
+ header.ts,
253
+ now
254
+ );
255
+ if (fresh !== "ok") return { refusal: fresh };
256
+ if (viaUnlinked) {
257
+ provenUnlinked = header.nonce;
258
+ return { refusal: "unknown_peer" };
259
+ }
260
+ claimed = `${header.from}:${header.nonce}`;
196
261
  return { peer: header.from };
197
262
  },
198
263
  ticket,
@@ -209,7 +274,7 @@ export async function handlePeerRpc(
209
274
  self: self.machineId,
210
275
  boxKey: deps.boxKey().privateKey,
211
276
  signKeyFor,
212
- replay: deps.replay,
277
+ replay: spent,
213
278
  now: deps.now?.(),
214
279
  });
215
280
  };
@@ -342,7 +407,8 @@ export function rpcDeps(port: number): RpcDeps {
342
407
  identity: () => currentIdentity(),
343
408
  boxKey: () => (box ??= loadBoxKey()),
344
409
  pinForMachine,
345
- anyActive: hasActivePin,
410
+ unlinked: (machineId) => unlinkedMachine(machineId),
411
+ anyActive: () => hasActivePin() || hasRecentUnlinked(),
346
412
  replay: createReplayGuard({ store: sqliteNonceStore() }),
347
413
  dispatch: loopbackDispatch(port),
348
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
 
@@ -26,7 +26,7 @@ const UNIT_MS: Record<string, number> = {
26
26
  d: 86_400_000,
27
27
  };
28
28
 
29
- function duration(text: string): number | null {
29
+ export function duration(text: string): number | null {
30
30
  let total = 0;
31
31
  const rest = text.replace(
32
32
  /(\d+(?:\.\d+)?|an?)\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/g,
@@ -18,6 +18,9 @@ export const DEVICE_COOKIE = "aos_device";
18
18
  export const DEVICE_HEADER = "x-agentos-device";
19
19
  export const TRUST_HEADER = "x-agentos-trust";
20
20
  export const REMOTE_HEADER = "x-agentos-remote";
21
+ // Who sent it, for per-sender limits: the visitor's address on a Connect
22
+ // request (lib/peer/rpc-intake sourceOf), an IPv6 address by its /64.
23
+ export const SOURCE_HEADER = "x-agentos-source";
21
24
  // The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
22
25
  // and the linked machine it verified. Never trusted from anywhere else.
23
26
  export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
@@ -81,12 +84,17 @@ const FORWARDING = new Set([
81
84
  const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
82
85
 
83
86
  // Reached without a token: the pairing page, what it needs to render, the
84
- // claim it posts, and a linked machine's rpc (its envelope is the proof). Exact paths, plus a few static asset folders whose
85
- // paths may only use plain characters (no traversal, no encoding).
87
+ // claim it posts, a linked machine's rpc (its envelope is the proof) and
88
+ // the pairing hellos (signed by a key the account lists). Exact paths, plus
89
+ // a few static asset folders whose paths may only use plain characters (no
90
+ // traversal, no encoding).
86
91
  const PUBLIC_EXACT = new Set([
87
92
  "/pair",
88
93
  "/api/pair/claim",
89
94
  "/api/peer/rpc",
95
+ "/api/peer/hello",
96
+ "/api/peer/hello/reveal",
97
+ "/api/peer/hello/status",
90
98
  "/manifest.webmanifest",
91
99
  "/manifest.json",
92
100
  "/favicon.ico",
@@ -49,23 +49,30 @@ export function assertDemoSandbox(env: Env = process.env, home = os.homedir()) {
49
49
  // "projects/[id]"): Next serves the folder, so an id must never match them.
50
50
  // The test walks app/api and fails when one is missing here.
51
51
  export const STATIC_SIBLINGS = [
52
+ "account",
52
53
  "archived",
53
54
  "arrived",
54
55
  "availability",
55
56
  "browse",
57
+ "check",
56
58
  "clone",
57
59
  "connect",
58
60
  "detect",
59
61
  "done-idle",
60
62
  "draft",
61
63
  "ended",
64
+ "exchange",
62
65
  "held-replies",
63
66
  "import",
64
67
  "init",
68
+ "group",
65
69
  "init-script",
66
70
  "me",
67
71
  "items",
72
+ "link",
68
73
  "network",
74
+ "order",
75
+ "pending",
69
76
  "pdf",
70
77
  "preview",
71
78
  "queue",
@@ -14,6 +14,7 @@ import {
14
14
  DEVICE_HEADER,
15
15
  TRUST_HEADER,
16
16
  REMOTE_HEADER,
17
+ SOURCE_HEADER,
17
18
  PEER_HEADER,
18
19
  PEER_INTERNAL_HEADER,
19
20
  type AuthPolicy,
@@ -25,6 +26,7 @@ import { tailscaleAddresses } from "./net";
25
26
  import { networkSetting } from "./network-settings";
26
27
  import { pairLocation } from "../session-url";
27
28
  import { peerSecret } from "../peer/internal";
29
+ import { sourceOf } from "../peer/rpc-intake";
28
30
 
29
31
  // Interface addresses change rarely; don't re-read them on every request.
30
32
  let tailnetCache = { at: 0, ips: [] as string[] };
@@ -63,6 +65,7 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
63
65
  delete req.headers[PEER_HEADER];
64
66
  const tunnel = isTunnelSocket(req.socket);
65
67
  req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
68
+ req.headers[SOURCE_HEADER] = sourceOf(req);
66
69
  let result: AuthResult;
67
70
  try {
68
71
  result = authorize(
@@ -0,0 +1,81 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { liveGroup } from "./store";
3
+ import { sessionWorkspaceId } from "./workspace";
4
+ import { GroupError, type SessionGroup } from "./types";
5
+
6
+ // The one place that checks the invariant: a session joins only a group of
7
+ // its own workspace. Membership is a view choice, so it leaves updated_at
8
+ // alone, like pinning.
9
+ export function checkMember(sessionId: string, group: SessionGroup): void {
10
+ const workspaceId = sessionWorkspaceId(sessionId);
11
+ if (workspaceId === undefined)
12
+ throw new GroupError("not-found", "Session not found");
13
+ if ((workspaceId ?? null) !== (group.workspace_id ?? null))
14
+ throw new GroupError(
15
+ "workspace",
16
+ `"${group.name}" is a group of another workspace`
17
+ );
18
+ }
19
+
20
+ // Files sessions into a group, or out of any (groupId null). All or none.
21
+ export function assignSessions(
22
+ sessionIds: readonly string[],
23
+ groupId: string | null
24
+ ): { moved: number } {
25
+ const ids = [...new Set(sessionIds)];
26
+ const group = groupId === null ? null : liveGroup(groupId);
27
+ const db = getDb();
28
+ return db.transaction(() => {
29
+ let moved = 0;
30
+ for (const id of ids) {
31
+ if (group) checkMember(id, group);
32
+ else if (sessionWorkspaceId(id) === undefined)
33
+ throw new GroupError("not-found", "Session not found");
34
+ moved += db
35
+ .prepare(
36
+ `UPDATE sessions SET session_group_id = ?
37
+ WHERE id = ? AND session_group_id IS NOT ?`
38
+ )
39
+ .run(group?.id ?? null, id, group?.id ?? null).changes;
40
+ }
41
+ return { moved };
42
+ })();
43
+ }
44
+
45
+ // For a session that is starting: its group may have been deleted, or its
46
+ // workspace changed, since it was asked for. Starting must not fail over a
47
+ // view choice, so it just starts ungrouped.
48
+ export function fileInGroup(sessionId: string, groupId: string): void {
49
+ try {
50
+ assignSessions([sessionId], groupId);
51
+ } catch (err) {
52
+ if (!(err instanceof GroupError)) throw err;
53
+ }
54
+ }
55
+
56
+ // A new session starts in the group of the one it came from (a worker from its
57
+ // conductor, a fork or a fresh start from its source), when that group is
58
+ // still live and in the new session's workspace.
59
+ export function inheritGroup(fromSessionId: string, toSessionId: string): void {
60
+ const row = getDb()
61
+ .prepare(`SELECT session_group_id AS id FROM sessions WHERE id = ?`)
62
+ .get(fromSessionId) as { id: string | null } | undefined;
63
+ if (row?.id) fileInGroup(toSessionId, row.id);
64
+ }
65
+
66
+ // Leaving a workspace leaves its groups (a project move into another one).
67
+ export function clearForeignGroup(sessionId: string): string | null {
68
+ const row = getDb()
69
+ .prepare(
70
+ `SELECT g.id AS id, g.name AS name, g.workspace_id AS ws
71
+ FROM sessions s JOIN session_groups g ON g.id = s.session_group_id
72
+ WHERE s.id = ?`
73
+ )
74
+ .get(sessionId) as
75
+ { id: string; name: string; ws: string | null } | undefined;
76
+ if (!row || (sessionWorkspaceId(sessionId) ?? null) === row.ws) return null;
77
+ getDb()
78
+ .prepare(`UPDATE sessions SET session_group_id = NULL WHERE id = ?`)
79
+ .run(sessionId);
80
+ return row.name;
81
+ }
@@ -0,0 +1,61 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { resolveReadable, readerScope } from "@/lib/bus/read-scope";
3
+ import { assignSessions } from "./assignSessions";
4
+ import { createGroup } from "./createGroup";
5
+ import { listGroups } from "./store";
6
+ import { updateGroup } from "./updateGroup";
7
+ import { resolveGroup } from "./resolveGroup";
8
+ import { GroupError } from "./types";
9
+
10
+ export type GroupCommand =
11
+ | { action: "list" }
12
+ | { action: "new"; name: string }
13
+ | { action: "rename"; group: string; name: string }
14
+ | { action: "add"; group: string; sessions: string[] }
15
+ | { action: "rm"; sessions: string[] };
16
+
17
+ // `aos group ...`: the caller's own workspace's groups, resolved the way
18
+ // `aos read` resolves sessions, so it reaches nothing outside that scope.
19
+ export function groupCommand(callerId: string, cmd: GroupCommand): string {
20
+ const scope = readerScope(callerId);
21
+ const ws = scope.workspaceId;
22
+ if (cmd.action === "list") return describe(ws, scope.label);
23
+ if (cmd.action === "new") {
24
+ const g = createGroup(ws, cmd.name);
25
+ return `created group "${g.name}" in your ${scope.label}`;
26
+ }
27
+ if (cmd.action === "rename") {
28
+ const from = resolveGroup(ws, cmd.group);
29
+ return `renamed "${from.name}" to "${updateGroup(from.id, { name: cmd.name }).name}"`;
30
+ }
31
+ if (!cmd.sessions.length)
32
+ throw new GroupError("invalid", "Name at least one session");
33
+ const target = cmd.action === "add" ? resolveGroup(ws, cmd.group) : null;
34
+ const ids = cmd.sessions.map((ref) => {
35
+ if (ref === "me") return callerId;
36
+ const found = resolveReadable(callerId, ref);
37
+ if (!found.ok) throw new GroupError("not-found", found.error);
38
+ return found.session.id;
39
+ });
40
+ assignSessions(ids, target?.id ?? null);
41
+ return target
42
+ ? `put ${ids.length} session${ids.length === 1 ? "" : "s"} in "${target.name}"`
43
+ : `took ${ids.length} session${ids.length === 1 ? "" : "s"} out of their group`;
44
+ }
45
+
46
+ function describe(workspaceId: string | null, label: string): string {
47
+ const groups = listGroups(workspaceId);
48
+ if (!groups.length) return `no groups in your ${label}`;
49
+ const counts = new Map(
50
+ (
51
+ getDb()
52
+ .prepare(
53
+ `SELECT session_group_id AS id, COUNT(*) AS n FROM sessions
54
+ WHERE session_group_id IS NOT NULL AND archived_at IS NULL
55
+ GROUP BY session_group_id`
56
+ )
57
+ .all() as { id: string; n: number }[]
58
+ ).map((r) => [r.id, r.n])
59
+ );
60
+ return groups.map((g) => `${g.name} · ${counts.get(g.id) ?? 0}`).join("\n");
61
+ }
@@ -0,0 +1,28 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getDb } from "@/lib/db";
3
+ import { cleanName, findGroup, groupByName } from "./store";
4
+ import { GroupError, type SessionGroup } from "./types";
5
+
6
+ // A new group goes to the bottom of its workspace's order. The name is
7
+ // unique within the workspace, ignoring case.
8
+ export function createGroup(
9
+ workspaceId: string | null,
10
+ name: unknown
11
+ ): SessionGroup {
12
+ const clean = cleanName(name);
13
+ const db = getDb();
14
+ if (
15
+ workspaceId &&
16
+ !db.prepare(`SELECT 1 FROM workspaces WHERE id = ?`).get(workspaceId)
17
+ )
18
+ throw new GroupError("not-found", "Workspace not found");
19
+ if (groupByName(workspaceId, clean))
20
+ throw new GroupError("duplicate", `There is already a group "${clean}"`);
21
+ const id = randomUUID();
22
+ db.prepare(
23
+ `INSERT INTO session_groups (id, workspace_id, name, sort_order)
24
+ VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order) + 1, 0)
25
+ FROM session_groups WHERE COALESCE(workspace_id, '') = COALESCE(?, '')))`
26
+ ).run(id, workspaceId, clean, workspaceId);
27
+ return findGroup(id)!;
28
+ }
@@ -0,0 +1,21 @@
1
+ export * from "./types";
2
+ export {
3
+ groupNames,
4
+ listGroups,
5
+ findGroup,
6
+ groupByName,
7
+ liveGroup,
8
+ } from "./store";
9
+ export { createGroup } from "./createGroup";
10
+ export { updateGroup } from "./updateGroup";
11
+ export { reorderGroups } from "./reorderGroups";
12
+ export { deleteGroup, restoreGroup } from "./removeGroup";
13
+ export {
14
+ assignSessions,
15
+ checkMember,
16
+ clearForeignGroup,
17
+ fileInGroup,
18
+ inheritGroup,
19
+ } from "./assignSessions";
20
+ export { resolveGroup } from "./resolveGroup";
21
+ export { sessionWorkspaceId } from "./workspace";
@@ -0,0 +1,36 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { findGroup, groupByName, liveGroup } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Soft delete: members keep their session_group_id and the sidebar ignores a
6
+ // deleted group, so they fall back to the status shelves and Undo (restore)
7
+ // puts them back. Nothing is hard-deleted.
8
+ export function deleteGroup(id: string): SessionGroup {
9
+ const group = liveGroup(id);
10
+ getDb()
11
+ .prepare(
12
+ `UPDATE session_groups SET deleted_at = datetime('now'),
13
+ updated_at = datetime('now') WHERE id = ?`
14
+ )
15
+ .run(id);
16
+ return group;
17
+ }
18
+
19
+ export function restoreGroup(id: string): SessionGroup {
20
+ const group = findGroup(id);
21
+ if (!group) throw new GroupError("not-found", "Group not found");
22
+ if (!group.deleted) return group;
23
+ // A new group took the name meanwhile.
24
+ if (groupByName(group.workspace_id, group.name))
25
+ throw new GroupError(
26
+ "duplicate",
27
+ `There is already a group "${group.name}"`
28
+ );
29
+ getDb()
30
+ .prepare(
31
+ `UPDATE session_groups SET deleted_at = NULL,
32
+ updated_at = datetime('now') WHERE id = ?`
33
+ )
34
+ .run(id);
35
+ return liveGroup(id);
36
+ }
@@ -0,0 +1,31 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { listGroups } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Sets a workspace's group order. `ids` may name only some of them (a move
6
+ // up or down sends the whole list; a stale list from another device leaves
7
+ // newer groups at the end), but never one from another workspace.
8
+ export function reorderGroups(
9
+ workspaceId: string | null,
10
+ ids: unknown
11
+ ): SessionGroup[] {
12
+ if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string"))
13
+ throw new GroupError("invalid", "ids is a list of group ids");
14
+ const current = listGroups(workspaceId);
15
+ const known = new Set(current.map((g) => g.id));
16
+ const wanted = [...new Set(ids as string[])];
17
+ const stranger = wanted.find((i) => !known.has(i));
18
+ if (stranger)
19
+ throw new GroupError("workspace", "A group is not in this workspace");
20
+ const order = [
21
+ ...wanted,
22
+ ...current.map((g) => g.id).filter((i) => !wanted.includes(i)),
23
+ ];
24
+ const db = getDb();
25
+ const set = db.prepare(
26
+ `UPDATE session_groups SET sort_order = ?, updated_at = datetime('now')
27
+ WHERE id = ? AND sort_order != ?`
28
+ );
29
+ db.transaction(() => order.forEach((id, i) => set.run(i, id, i)))();
30
+ return listGroups(workspaceId);
31
+ }
@@ -0,0 +1,19 @@
1
+ import { groupByName, listGroups } from "./store";
2
+ import { GroupError, type SessionGroup } from "./types";
3
+
4
+ // A group named by a caller (`aos --group`, an orchestrator tool): it must
5
+ // exist in that workspace, and the error lists the ones that do.
6
+ export function resolveGroup(
7
+ workspaceId: string | null,
8
+ name: string
9
+ ): SessionGroup {
10
+ const found = groupByName(workspaceId, name);
11
+ if (found) return found;
12
+ const names = listGroups(workspaceId).map((g) => `"${g.name}"`);
13
+ throw new GroupError(
14
+ "not-found",
15
+ names.length
16
+ ? `No group "${name.trim()}" in this workspace. Groups: ${names.join(", ")}`
17
+ : `No group "${name.trim()}": this workspace has no groups`
18
+ );
19
+ }