@saadnvd1/agent-os 0.3.698 → 0.3.715

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,73 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import {
4
+ claimsShared,
5
+ claimsView,
6
+ liveClaims,
7
+ registerOutside,
8
+ } from "@/lib/claims";
9
+ import { outsideBody } from "@/lib/claims/types";
10
+ import { repoKey } from "@/lib/git-remote";
11
+ import { getDb, queries, type Session } from "@/lib/db";
12
+ import { getProject } from "@/lib/projects";
13
+ import { expandHome } from "@/lib/tasks/session";
14
+
15
+ export const dynamic = "force-dynamic";
16
+
17
+ const query = z.object({
18
+ session: z.string().max(100).optional(),
19
+ project: z.string().max(100).optional(),
20
+ repo: z.string().max(300).optional(),
21
+ });
22
+
23
+ // GET /api/claims - what live sessions, here and on linked machines, are
24
+ // working on, with the pairs that overlap and when each machine was last
25
+ // heard from. `session` or `project` narrows it to that repository.
26
+ export async function GET(request: NextRequest) {
27
+ const q = query.safeParse(Object.fromEntries(request.nextUrl.searchParams));
28
+ if (!q.success)
29
+ return NextResponse.json({ error: "Bad query" }, { status: 400 });
30
+ let repo = q.data.repo?.toLowerCase();
31
+ if (q.data.session) {
32
+ const s = queries.getSession(getDb()).get(q.data.session) as
33
+ Session | undefined;
34
+ if (!s)
35
+ return NextResponse.json({ error: "No such session" }, { status: 404 });
36
+ repo =
37
+ (await repoKey(expandHome(s.worktree_path || s.working_directory))) ??
38
+ undefined;
39
+ if (!repo) return NextResponse.json(shape([]));
40
+ }
41
+ if (q.data.project) {
42
+ const p = getProject(q.data.project);
43
+ if (!p)
44
+ return NextResponse.json({ error: "No such project" }, { status: 404 });
45
+ repo = (await repoKey(expandHome(p.working_directory))) ?? undefined;
46
+ if (!repo) return NextResponse.json(shape([]));
47
+ }
48
+ return NextResponse.json(shape(liveClaims({ repo })));
49
+ }
50
+
51
+ // POST /api/claims - a session AgentOS didn't start registers or renews its
52
+ // claim (`aos claim start|touch`). The CLI read git in its own folder and
53
+ // sends the remote URL, branch and paths; nothing is run here.
54
+ export async function POST(request: NextRequest) {
55
+ const body = outsideBody.safeParse(await request.json().catch(() => null));
56
+ if (!body.success)
57
+ return NextResponse.json({ error: "Bad claim" }, { status: 400 });
58
+ if (!claimsShared())
59
+ return NextResponse.json({ error: "Claims are off" }, { status: 409 });
60
+ const result = registerOutside(body.data);
61
+ if (!result)
62
+ return NextResponse.json(
63
+ { error: "No repository remote, or that id belongs to another claim" },
64
+ { status: 422 }
65
+ );
66
+ const claims = liveClaims({ repo: result.claim.repo });
67
+ const overlaps = shape(claims).overlaps.filter(
68
+ (o) => o.a === result.claim.id || o.b === result.claim.id
69
+ );
70
+ return NextResponse.json({ ...shape(claims), id: result.claim.id, overlaps });
71
+ }
72
+
73
+ const shape = (claims: ReturnType<typeof liveClaims>) => claimsView(claims);
@@ -1,12 +1,20 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { deleteHost } from "@/lib/hosts";
3
+ import { pinForHost } from "@/lib/peer/links";
3
4
 
4
5
  export async function DELETE(
5
6
  _request: NextRequest,
6
7
  { params }: { params: Promise<{ id: string }> }
7
8
  ) {
8
9
  try {
9
- deleteHost((await params).id);
10
+ const id = (await params).id;
11
+ // Removing it here would leave the other machine linked to this one.
12
+ if (pinForHost(id))
13
+ return NextResponse.json(
14
+ { error: "It's linked through Connect: unlink it first." },
15
+ { status: 409 }
16
+ );
17
+ deleteHost(id);
10
18
  return NextResponse.json({ success: true });
11
19
  } catch (error) {
12
20
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,6 @@
1
+ import { helloRoute } from "@/lib/peer/hello-route";
2
+ import { receiveReveal } from "@/lib/peer/pairing";
3
+
4
+ // POST /api/peer/hello/reveal - public: the asking machine reveals the nonce
5
+ // its hello committed to.
6
+ export const POST = helloRoute((body) => receiveReveal(body));
@@ -0,0 +1,9 @@
1
+ import { helloRoute } from "@/lib/peer/hello-route";
2
+ import { receiveHello } from "@/lib/peer/pairing";
3
+ import { SOURCE_HEADER } from "@/lib/security/auth";
4
+
5
+ // POST /api/peer/hello - public: another machine on this Connect account
6
+ // asks to link (lib/peer/pairing.ts).
7
+ export const POST = helloRoute((body, request) =>
8
+ receiveHello(body, undefined, request.headers.get(SOURCE_HEADER) ?? "unknown")
9
+ );
@@ -0,0 +1,6 @@
1
+ import { helloRoute } from "@/lib/peer/hello-route";
2
+ import { receiveStatus } from "@/lib/peer/pairing";
3
+
4
+ // POST /api/peer/hello/status - public: the asking machine learns, signed,
5
+ // whether the code was typed here.
6
+ export const POST = helloRoute((body) => receiveStatus(body));
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { PEER_HEADER, TRUST_HEADER } from "@/lib/security/auth";
3
+ import { getHost } from "@/lib/hosts";
4
+ import { unlinkedByPeer } from "@/lib/peer/links";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ // POST /api/peer/unlink - a linked machine unlinked this one. Reached only
9
+ // through its signed envelope (lib/peer/rpc-server.ts), which the gate turns
10
+ // into `peer` trust naming the host it's pinned to; nothing else may call it.
11
+ export function POST(request: NextRequest) {
12
+ const hostId = request.headers.get(PEER_HEADER);
13
+ if (request.headers.get(TRUST_HEADER) !== "peer" || !hostId)
14
+ return NextResponse.json({ error: "forbidden" }, { status: 403 });
15
+ const name = getHost(hostId)?.name ?? "the other machine";
16
+ return NextResponse.json({ unlinked: unlinkedByPeer(hostId, name) });
17
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { peerAudit } from "@/lib/peer/audit";
4
+
5
+ // GET /api/peers/:hostId/audit - every call to and from that linked machine,
6
+ // newest first: Machines → Activity.
7
+ export async function GET(
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ hostId: string }> }
10
+ ) {
11
+ const gate = requireApprover(request);
12
+ if (!gate.ok) return gate.response;
13
+ return NextResponse.json({ rows: peerAudit((await params).hostId) });
14
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { unlinkPeer } from "@/lib/peer/unlink";
4
+
5
+ // DELETE /api/peers/:hostId - unlink a machine linked through Connect: its
6
+ // pin goes here at once, and it's told to drop its side. Takes the same
7
+ // trust as linking.
8
+ export async function DELETE(
9
+ request: NextRequest,
10
+ { params }: { params: Promise<{ hostId: string }> }
11
+ ) {
12
+ const gate = requireApprover(request);
13
+ if (!gate.ok) return gate.response;
14
+ try {
15
+ return NextResponse.json(await unlinkPeer((await params).hostId));
16
+ } catch (err) {
17
+ const error = err instanceof Error ? err.message : String(err);
18
+ return NextResponse.json({ error }, { status: 404 });
19
+ }
20
+ }
@@ -0,0 +1,9 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { accountMachines } from "@/lib/peer/machines";
3
+
4
+ // GET /api/peers/account[?refresh=1] - the other machines on this Connect
5
+ // account, whether each is linked here, and whether the plan allows linking.
6
+ export async function GET(request: NextRequest) {
7
+ const refresh = request.nextUrl.searchParams.get("refresh") === "1";
8
+ return NextResponse.json(await accountMachines(refresh));
9
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { mintDevice } from "@/lib/security/devices";
4
+ import { isPublicPath } from "@/lib/security/auth";
5
+
6
+ const accept = await import("./pending/[id]/accept/route");
7
+ const decline = await import("./pending/[id]/decline/route");
8
+ const link = await import("./link/route");
9
+ const cancel = await import("./link/[id]/route");
10
+ const confirm = await import("./link/[id]/confirm/route");
11
+ const hello = await import("../peer/hello/route");
12
+ const unlink = await import("./[hostId]/route");
13
+ const audit = await import("./[hostId]/audit/route");
14
+ const hostDelete = await import("../hosts/[id]/route");
15
+
16
+ const plain = mintDevice("phone").device.id;
17
+ const approver = mintDevice("tablet", null, true).device.id;
18
+
19
+ const req = (
20
+ path: string,
21
+ o: { trust?: string; device?: string; body?: string; method?: string } = {}
22
+ ) =>
23
+ new NextRequest(`http://localhost${path}`, {
24
+ method: o.method ?? "POST",
25
+ headers: {
26
+ ...(o.trust && { "x-agentos-trust": o.trust }),
27
+ ...(o.device && { "x-agentos-device": o.device }),
28
+ "content-type": "application/json",
29
+ },
30
+ body:
31
+ o.method === "GET"
32
+ ? undefined
33
+ : (o.body ?? JSON.stringify({ code: "123456" })),
34
+ });
35
+ const params = { params: Promise.resolve({ id: "nope" }) };
36
+
37
+ describe("linking routes", () => {
38
+ it("take a code only from this machine, the tailnet or a Can approve device", async () => {
39
+ for (const who of [
40
+ {},
41
+ { trust: "device", device: plain },
42
+ { trust: "device", device: "unknown" },
43
+ { trust: "peer" },
44
+ { trust: "open" },
45
+ ]) {
46
+ const r = await accept.POST(
47
+ req("/api/peers/pending/nope/accept", who),
48
+ params
49
+ );
50
+ expect(r.status).toBe(403);
51
+ expect((await decline.POST(req("/x", who), params)).status).toBe(403);
52
+ expect((await confirm.POST(req("/x", who), params)).status).toBe(403);
53
+ expect((await link.POST(req("/x", { ...who, body: "{}" }))).status).toBe(
54
+ 403
55
+ );
56
+ expect(
57
+ (
58
+ await cancel.DELETE(
59
+ req("/x", { ...who, method: "DELETE", body: "" }),
60
+ params
61
+ )
62
+ ).status
63
+ ).toBe(403);
64
+ }
65
+ // Past the gate: there is no such request.
66
+ for (const who of [
67
+ { trust: "loopback" },
68
+ { trust: "tailnet" },
69
+ { trust: "device", device: approver },
70
+ ])
71
+ expect(
72
+ (await accept.POST(req("/api/peers/pending/nope/accept", who), params))
73
+ .status
74
+ ).toBe(404);
75
+ expect(
76
+ (await confirm.POST(req("/x", { trust: "loopback" }), params)).status
77
+ ).toBe(404);
78
+ expect(
79
+ (await link.POST(req("/x", { trust: "loopback", body: "{}" }))).status
80
+ ).toBe(400);
81
+ for (const body of ['{"code":482913}', "{", "{}"])
82
+ for (const route of [accept, confirm])
83
+ expect(
84
+ (await route.POST(req("/x", { trust: "loopback", body }), params))
85
+ .status
86
+ ).toBe(400);
87
+ });
88
+
89
+ it("caps a hello at 4 KiB and refuses junk", async () => {
90
+ const big = await hello.POST(
91
+ req("/api/peer/hello", { body: "x".repeat(5000) })
92
+ );
93
+ expect(big.status).toBe(413);
94
+ expect(
95
+ (await hello.POST(req("/api/peer/hello", { body: "{" }))).status
96
+ ).toBe(400);
97
+ expect(big.headers.get("cache-control")).toBe("no-store");
98
+ });
99
+
100
+ it("are public only for the three hello paths", () => {
101
+ for (const p of [
102
+ "/api/peer/hello",
103
+ "/api/peer/hello/reveal",
104
+ "/api/peer/hello/status",
105
+ ])
106
+ expect(isPublicPath(p)).toBe(true);
107
+ for (const p of [
108
+ "/api/peer/hello/x",
109
+ "/api/peer/hello/../../pair/start",
110
+ "/api/peers/pending",
111
+ "/api/peers/account",
112
+ "/api/peers/link",
113
+ "/api/peers/link/x/confirm",
114
+ "/api/peers/h1",
115
+ "/api/peers/h1/audit",
116
+ "/api/peer/unlink",
117
+ ])
118
+ expect(isPublicPath(p)).toBe(false);
119
+ });
120
+
121
+ it("unlink and Activity take the same trust as linking", async () => {
122
+ const hostParams = { params: Promise.resolve({ hostId: "nope" }) };
123
+ for (const who of [
124
+ {},
125
+ { trust: "device", device: plain },
126
+ { trust: "peer" },
127
+ { trust: "open" },
128
+ ]) {
129
+ const del = req("/api/peers/nope", {
130
+ ...who,
131
+ method: "DELETE",
132
+ body: "",
133
+ });
134
+ expect((await unlink.DELETE(del, hostParams)).status).toBe(403);
135
+ const get = req("/api/peers/nope/audit", { ...who, method: "GET" });
136
+ expect((await audit.GET(get, hostParams)).status).toBe(403);
137
+ }
138
+ const asApprover = { trust: "device", device: approver };
139
+ expect(
140
+ (
141
+ await unlink.DELETE(
142
+ req("/x", { ...asApprover, method: "DELETE", body: "" }),
143
+ hostParams
144
+ )
145
+ ).status
146
+ ).toBe(404);
147
+ const rows = await audit.GET(
148
+ req("/x", { ...asApprover, method: "GET" }),
149
+ hostParams
150
+ );
151
+ expect(await rows.json()).toEqual({ rows: [] });
152
+ });
153
+
154
+ it("won't remove a Connect-linked machine without unlinking it", async () => {
155
+ const { createConnectHost } = await import("@/lib/hosts");
156
+ const { savePin, pinForHost } = await import("@/lib/peer/links");
157
+ const id = createConnectHost("Linked").id;
158
+ savePin({
159
+ hostId: id,
160
+ machineId: "f0f0f0f0",
161
+ hostname: "m-0000000000000000.runagentos.com",
162
+ signPub: "x",
163
+ boxPub: "y",
164
+ });
165
+ const res = await hostDelete.DELETE(
166
+ req("/x", { method: "DELETE", body: "" }),
167
+ {
168
+ params: Promise.resolve({ id }),
169
+ }
170
+ );
171
+ expect(res.status).toBe(409);
172
+ expect(pinForHost(id)).not.toBeNull();
173
+ });
174
+ });
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { confirmLink } from "@/lib/peer/pairing-out";
5
+
6
+ const bodySchema = z.object({ code: z.string().max(32) });
7
+
8
+ // POST /api/peers/link/:id/confirm {code} - the code the other machine shows
9
+ // once it took ours, typed here: only then is it pinned. Same gate as
10
+ // starting the link.
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const gate = requireApprover(request);
16
+ if (!gate.ok) return gate.response;
17
+ const body = bodySchema.safeParse(await request.json().catch(() => null));
18
+ if (!body.success)
19
+ return NextResponse.json({ error: "code required" }, { status: 400 });
20
+ const result = confirmLink((await params).id, body.data.code);
21
+ if (!result.ok)
22
+ return NextResponse.json(
23
+ { error: result.error, attemptsLeft: result.attemptsLeft },
24
+ { status: result.status }
25
+ );
26
+ return NextResponse.json({ link: result.link });
27
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { cancelLink, outgoingLink } from "@/lib/peer/pairing-out";
4
+
5
+ type Ctx = { params: Promise<{ id: string }> };
6
+
7
+ // GET /api/peers/link/:id - a link this machine started: its code and state.
8
+ export async function GET(_request: NextRequest, { params }: Ctx) {
9
+ const link = outgoingLink((await params).id);
10
+ if (!link)
11
+ return NextResponse.json({ error: "No such request" }, { status: 404 });
12
+ return NextResponse.json({ link });
13
+ }
14
+
15
+ // DELETE /api/peers/link/:id - stop waiting for it.
16
+ export async function DELETE(request: NextRequest, { params }: Ctx) {
17
+ const gate = requireApprover(request);
18
+ if (!gate.ok) return gate.response;
19
+ return NextResponse.json({ cancelled: cancelLink((await params).id) });
20
+ }
@@ -0,0 +1,23 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { LinkError, startLink } from "@/lib/peer/pairing-out";
5
+
6
+ const bodySchema = z.object({ machineId: z.string().min(1).max(64) });
7
+
8
+ // POST /api/peers/link {machineId} - ask an account machine to link. Once
9
+ // linked it can run code here too, so only a trusted place may start it.
10
+ export async function POST(request: NextRequest) {
11
+ const gate = requireApprover(request);
12
+ if (!gate.ok) return gate.response;
13
+ const body = bodySchema.safeParse(await request.json().catch(() => null));
14
+ if (!body.success)
15
+ return NextResponse.json({ error: "machineId required" }, { status: 400 });
16
+ try {
17
+ return NextResponse.json({ link: await startLink(body.data.machineId) });
18
+ } catch (error) {
19
+ const status = error instanceof LinkError ? error.status : 500;
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ return NextResponse.json({ error: message }, { status });
22
+ }
23
+ }
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { acceptRequest } from "@/lib/peer/pairing";
5
+
6
+ const bodySchema = z.object({ code: z.string().max(32) });
7
+
8
+ // POST /api/peers/pending/:id/accept {code} - the code the other machine
9
+ // shows, typed here. Linking lets that machine run code here, so it takes
10
+ // this machine, the tailnet or a device with Can approve.
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const gate = requireApprover(request);
16
+ if (!gate.ok) return gate.response;
17
+ const body = bodySchema.safeParse(await request.json().catch(() => null));
18
+ if (!body.success)
19
+ return NextResponse.json({ error: "code required" }, { status: 400 });
20
+ const result = await acceptRequest((await params).id, body.data.code);
21
+ if (!result.ok)
22
+ return NextResponse.json(
23
+ { error: result.error, attemptsLeft: result.attemptsLeft },
24
+ { status: result.status }
25
+ );
26
+ return NextResponse.json({
27
+ hostId: result.hostId,
28
+ name: result.name,
29
+ confirmCode: result.confirmCode,
30
+ });
31
+ }
@@ -0,0 +1,13 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { declineRequest } from "@/lib/peer/pairing";
4
+
5
+ // POST /api/peers/pending/:id/decline
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ const gate = requireApprover(request);
11
+ if (!gate.ok) return gate.response;
12
+ return NextResponse.json({ declined: declineRequest((await params).id) });
13
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { mintDevice } from "@/lib/security/devices";
4
+
5
+ const seen: unknown[] = [];
6
+ vi.mock("@/lib/peer/pairing", () => ({
7
+ pendingRequests: (_now: number, o: unknown) => {
8
+ seen.push(o);
9
+ return [];
10
+ },
11
+ }));
12
+ const { GET } = await import("./route");
13
+
14
+ const get = (headers: Record<string, string>) =>
15
+ GET(new NextRequest("http://localhost/api/peers/pending", { headers }));
16
+
17
+ describe("GET /api/peers/pending", () => {
18
+ it("hands out the second code only to a caller that could accept", async () => {
19
+ const plain = mintDevice("phone").device.id;
20
+ const approver = mintDevice("tablet", null, true).device.id;
21
+ for (const h of [
22
+ {} as Record<string, string>,
23
+ { "x-agentos-trust": "device", "x-agentos-device": plain },
24
+ { "x-agentos-trust": "peer" },
25
+ ])
26
+ expect((await get(h)).status).toBe(200);
27
+ await get({ "x-agentos-trust": "loopback" });
28
+ await get({ "x-agentos-trust": "device", "x-agentos-device": approver });
29
+ expect(seen).toEqual([
30
+ { withCodes: false },
31
+ { withCodes: false },
32
+ { withCodes: false },
33
+ { withCodes: true },
34
+ { withCodes: true },
35
+ ]);
36
+ });
37
+ });
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { approverOf } from "@/lib/security/approver";
3
+ import { pendingRequests } from "@/lib/peer/pairing";
4
+
5
+ // GET /api/peers/pending - machines asking to link here, waiting for the
6
+ // code, and accepted ones whose second code is still to be typed there. That
7
+ // code goes only to a caller that could have accepted.
8
+ export function GET(request: NextRequest) {
9
+ return NextResponse.json({
10
+ requests: pendingRequests(Date.now(), {
11
+ withCodes: approverOf(request.headers) !== null,
12
+ }),
13
+ });
14
+ }
@@ -0,0 +1,11 @@
1
+ import { restoreGroup } from "@/lib/session-groups";
2
+ import { run } from "@/lib/session-groups/route";
3
+
4
+ // POST /api/session-groups/:id/restore - undo a delete.
5
+ export async function POST(
6
+ _request: Request,
7
+ { params }: { params: Promise<{ id: string }> }
8
+ ) {
9
+ const { id } = await params;
10
+ return run(() => ({ sessionGroup: restoreGroup(id) }));
11
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import { deleteGroup, updateGroup } from "@/lib/session-groups";
3
+ import { groupRoute, run } from "@/lib/session-groups/route";
4
+
5
+ type Ctx = { params: Promise<{ id: string }> };
6
+
7
+ const patch = z.object({
8
+ name: z.string().optional(),
9
+ collapsed: z.boolean().optional(),
10
+ });
11
+
12
+ // PATCH /api/session-groups/:id { name?, collapsed? } - rename or fold.
13
+ export async function PATCH(request: Request, { params }: Ctx) {
14
+ const { id } = await params;
15
+ return groupRoute(request, patch, (b) => ({
16
+ sessionGroup: updateGroup(id, b),
17
+ }));
18
+ }
19
+
20
+ // DELETE /api/session-groups/:id - soft delete; its sessions fall back to the
21
+ // status shelves and restore brings them back.
22
+ export async function DELETE(_request: Request, { params }: Ctx) {
23
+ const { id } = await params;
24
+ return run(() => ({ sessionGroup: deleteGroup(id) }));
25
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import { reorderGroups } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const order = z.object({
6
+ workspaceId: z.string().nullable(),
7
+ ids: z.array(z.string()),
8
+ });
9
+
10
+ // POST /api/session-groups/order { workspaceId, ids } - a workspace's order.
11
+ export async function POST(request: Request) {
12
+ return groupRoute(request, order, (b) => ({
13
+ sessionGroups: reorderGroups(b.workspaceId, b.ids),
14
+ }));
15
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { createGroup, listGroups } from "@/lib/session-groups";
3
+ import { groupRoute, run } from "@/lib/session-groups/route";
4
+
5
+ const create = z.object({
6
+ name: z.string({ error: "A group needs a name" }),
7
+ workspaceId: z.string().nullable(),
8
+ });
9
+
10
+ // GET /api/session-groups - every live group.
11
+ export async function GET() {
12
+ return run(() => ({ sessionGroups: listGroups() }));
13
+ }
14
+
15
+ // POST /api/session-groups { name, workspaceId } - create one at the bottom
16
+ // of its workspace's order.
17
+ export async function POST(request: Request) {
18
+ return groupRoute(request, create, (b) => ({
19
+ sessionGroup: createGroup(b.workspaceId, b.name),
20
+ }));
21
+ }
@@ -1,3 +1,4 @@
1
+ import { inheritGroup } from "@/lib/session-groups/assignSessions";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { randomUUID } from "crypto";
3
4
  import { getDb, queries, type Session, type Message } from "@/lib/db";
@@ -63,6 +64,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
63
64
  parent.host_id
64
65
  );
65
66
 
67
+ inheritGroup(parentId, newId);
68
+
66
69
  // NOTE: We do NOT copy claude_session_id here.
67
70
  // When the forked session is first attached, it will use --fork-session flag
68
71
  // with the parent's claude_session_id to create a new branched conversation.
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import { assignSessions } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const body = z.object({ groupId: z.string().nullable() });
6
+
7
+ // POST /api/sessions/:id/group { groupId | null } - join a group of the
8
+ // session's workspace, or leave.
9
+ export async function POST(
10
+ request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const { id } = await params;
14
+ return groupRoute(request, body, (b) => ({
15
+ ok: true,
16
+ ...assignSessions([id], b.groupId),
17
+ }));
18
+ }