@saadnvd1/agent-os 0.3.561 → 0.3.564

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.
@@ -1,9 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import {
3
- renameDevice,
4
- revokeDevice,
5
- setDeviceCanApprove,
6
- } from "@/lib/security/devices";
2
+ import { renameDevice, setDeviceCanApprove } from "@/lib/security/devices";
3
+ import { revokeDeviceAndKeys } from "@/lib/orchestrator/passkey-asks";
7
4
  import { requireLocalTrust } from "@/lib/security/route-guard";
8
5
  import { DEVICE_HEADER } from "@/lib/security/auth";
9
6
  import { demoMode } from "@/lib/security/demo";
@@ -43,7 +40,7 @@ export async function DELETE(request: NextRequest, { params }: Ctx) {
43
40
  { error: "Not available in the demo." },
44
41
  { status: 403 }
45
42
  );
46
- if (!revokeDevice(id))
43
+ if (!revokeDeviceAndKeys(id, "Devices"))
47
44
  return NextResponse.json({ error: "Not found" }, { status: 404 });
48
45
  return NextResponse.json({ ok: true });
49
46
  }
@@ -1,12 +1,12 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { revokeDevice } from "@/lib/security/devices";
2
+ import { revokeDeviceAndKeys } from "@/lib/orchestrator/passkey-asks";
3
3
  import { callingDevice, needsDevice } from "@/lib/security/device-me";
4
4
 
5
5
  // DELETE /api/devices/me - the device signs itself out ("Forget machine")
6
6
  export async function DELETE(request: NextRequest) {
7
7
  const id = callingDevice(request);
8
8
  if (!id) return needsDevice();
9
- if (!revokeDevice(id))
9
+ if (!revokeDeviceAndKeys(id, "the device itself"))
10
10
  return NextResponse.json({ error: "Not found" }, { status: 404 });
11
11
  return NextResponse.json({ ok: true });
12
12
  }
@@ -0,0 +1,36 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import {
4
+ deviceRegistrationOptions,
5
+ PresenceError,
6
+ } from "@/lib/security/presence";
7
+ import { refusalResponse } from "@/lib/security/presence-http";
8
+ import { passkeysBootstrapped } from "@/lib/security/passkeys";
9
+
10
+ // A challenge for this paired device's new approval key to sign. The key is
11
+ // the device's own, so only a paired device asks; with passkeys already set
12
+ // up, it needs a code from one of them, like a browser passkey does.
13
+ export async function POST(request: NextRequest) {
14
+ const gate = requireApprover(request);
15
+ if (!gate.ok) return gate.response;
16
+ if (gate.approver.via !== "device" || !gate.approver.deviceId)
17
+ return NextResponse.json(
18
+ { error: "Only a paired device has its own key" },
19
+ { status: 403 }
20
+ );
21
+ try {
22
+ const { enrollCode } = (await request.json().catch(() => ({}))) as {
23
+ enrollCode?: string;
24
+ };
25
+ return NextResponse.json(
26
+ deviceRegistrationOptions(gate.approver.deviceId, enrollCode)
27
+ );
28
+ } catch (error) {
29
+ if (error instanceof PresenceError && error.code === "enroll")
30
+ return NextResponse.json({
31
+ needsCode: true,
32
+ canBootstrap: !passkeysBootstrapped(),
33
+ });
34
+ return refusalResponse(error);
35
+ }
36
+ }
@@ -0,0 +1,55 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { verifyDeviceRegistration } from "@/lib/security/presence";
4
+ import { placeOf, refusalResponse } from "@/lib/security/presence-http";
5
+ import { raisePasskeyAsks } from "@/lib/orchestrator/passkey-asks";
6
+ import { resolvePasskeyAsks } from "@/lib/orchestrator/asks";
7
+
8
+ // Stores the paired device's own approval key, and puts it on Saad's asks
9
+ // list to confirm. A key it replaces (a reinstall) is revoked and noted.
10
+ export async function POST(request: NextRequest) {
11
+ const gate = requireApprover(request);
12
+ if (!gate.ok) return gate.response;
13
+ if (gate.approver.via !== "device" || !gate.approver.deviceId)
14
+ return NextResponse.json(
15
+ { error: "Only a paired device has its own key" },
16
+ { status: 403 }
17
+ );
18
+ try {
19
+ const body = (await request.json().catch(() => ({}))) as {
20
+ publicKey?: string;
21
+ challenge?: string;
22
+ signature?: string;
23
+ name?: string;
24
+ hardware?: string;
25
+ enrollCode?: string;
26
+ };
27
+ if (!body.publicKey || !body.challenge || !body.signature)
28
+ return NextResponse.json({ error: "No key" }, { status: 400 });
29
+ const place = placeOf(gate.approver, request.headers);
30
+ const { key, replaced } = verifyDeviceRegistration(
31
+ gate.approver.deviceId,
32
+ {
33
+ publicKey: body.publicKey,
34
+ challenge: body.challenge,
35
+ signature: body.signature,
36
+ name: body.name || place,
37
+ hardware: body.hardware,
38
+ },
39
+ {
40
+ via: gate.approver.via,
41
+ from: place,
42
+ userAgent: request.headers.get("user-agent"),
43
+ },
44
+ body.enrollCode
45
+ );
46
+ for (const old of replaced) {
47
+ resolvePasskeyAsks(old.id, "replaced");
48
+ raisePasskeyAsks(old, place, "revoked");
49
+ }
50
+ raisePasskeyAsks(key, place);
51
+ return NextResponse.json({ passkey: { id: key.id, name: key.name } });
52
+ } catch (error) {
53
+ return refusalResponse(error);
54
+ }
55
+ }
@@ -1,12 +1,11 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
2
  import { requireApprover } from "@/lib/security/approver";
3
+ import { mintEnrollCode } from "@/lib/security/presence";
4
4
  import {
5
- mintEnrollCode,
6
- relyingParty,
7
- verifyPresence,
8
- } from "@/lib/security/presence";
9
- import { refusalResponse } from "@/lib/security/presence-http";
5
+ refusalResponse,
6
+ requirePresence,
7
+ type PresenceProof,
8
+ } from "@/lib/security/presence-http";
10
9
 
11
10
  // A one-time code (10 minutes) for adding a passkey on another device,
12
11
  // minted only on an assertion from an existing passkey.
@@ -14,15 +13,8 @@ export async function POST(request: NextRequest) {
14
13
  const gate = requireApprover(request);
15
14
  if (!gate.ok) return gate.response;
16
15
  try {
17
- const { assertion } = (await request.json().catch(() => ({}))) as {
18
- assertion?: AuthenticationResponseJSON;
19
- };
20
- await verifyPresence(
21
- relyingParty(request.headers),
22
- assertion,
23
- "enroll",
24
- "enroll"
25
- );
16
+ const body = (await request.json().catch(() => ({}))) as PresenceProof;
17
+ await requirePresence(request, gate.approver, body, "enroll", "enroll");
26
18
  return NextResponse.json({ code: mintEnrollCode() });
27
19
  } catch (error) {
28
20
  return refusalResponse(error);
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { requireApprover } from "@/lib/security/approver";
3
3
  import {
4
4
  assertionOptions,
5
+ deviceAssertionOptions,
5
6
  relyingParty,
6
7
  type PresencePurpose,
7
8
  } from "@/lib/security/presence";
@@ -21,8 +22,27 @@ export async function POST(request: NextRequest) {
21
22
  askId?: number;
22
23
  binding?: string;
23
24
  passkeyId?: string;
25
+ method?: "device";
24
26
  };
25
27
  const binding = presenceBinding(body);
28
+ if (body.method === "device") {
29
+ if (gate.approver.via !== "device" || !gate.approver.deviceId)
30
+ return NextResponse.json(
31
+ { error: "Only a paired device has its own key" },
32
+ { status: 403 }
33
+ );
34
+ const device = deviceAssertionOptions(
35
+ gate.approver.deviceId,
36
+ body.purpose!,
37
+ binding
38
+ );
39
+ if (!device)
40
+ return NextResponse.json({
41
+ needsDeviceKey: true,
42
+ canBootstrap: !passkeysBootstrapped(),
43
+ });
44
+ return NextResponse.json({ device });
45
+ }
26
46
  const rp = relyingParty(request.headers);
27
47
  const options = await assertionOptions(rp, body.purpose!, binding);
28
48
  if (!options)
@@ -1,9 +1,12 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
2
  import { requireApprover } from "@/lib/security/approver";
4
3
  import { getPasskey, revokePasskey } from "@/lib/security/passkeys";
5
- import { relyingParty, verifyPresence } from "@/lib/security/presence";
6
- import { placeOf, refusalResponse } from "@/lib/security/presence-http";
4
+ import {
5
+ placeOf,
6
+ refusalResponse,
7
+ requirePresence,
8
+ type PresenceProof,
9
+ } from "@/lib/security/presence-http";
7
10
  import { raisePasskeyAsks } from "@/lib/orchestrator/passkey-asks";
8
11
  import { revokePresence } from "@/lib/orchestrator/presence-binding";
9
12
  import { resolvePasskeyAsks } from "@/lib/orchestrator/asks";
@@ -21,12 +24,11 @@ export async function DELETE(request: NextRequest, { params }: Ctx) {
21
24
  const key = getPasskey(id);
22
25
  if (!key || key.revoked_at)
23
26
  return NextResponse.json({ error: "Not found" }, { status: 404 });
24
- const { assertion } = (await request.json().catch(() => ({}))) as {
25
- assertion?: AuthenticationResponseJSON;
26
- };
27
- await verifyPresence(
28
- relyingParty(request.headers),
29
- assertion,
27
+ const body = (await request.json().catch(() => ({}))) as PresenceProof;
28
+ await requirePresence(
29
+ request,
30
+ gate.approver,
31
+ body,
30
32
  "revoke",
31
33
  revokePresence(id)
32
34
  );
@@ -1,8 +1,11 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
2
  import { requireApprover } from "@/lib/security/approver";
4
- import { relyingParty, verifyPresence } from "@/lib/security/presence";
5
- import { placeOf, refusalResponse } from "@/lib/security/presence-http";
3
+ import {
4
+ placeOf,
5
+ refusalResponse,
6
+ requirePresence,
7
+ type PresenceProof,
8
+ } from "@/lib/security/presence-http";
6
9
  import { getPasskey } from "@/lib/security/passkeys";
7
10
  import { raisePasskeyAsks } from "@/lib/orchestrator/passkey-asks";
8
11
  import {
@@ -40,10 +43,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
40
43
  : requireApprover(request);
41
44
  if (!gate.ok) return gate.response;
42
45
  const { id, askId } = await params;
43
- const body = (await request.json().catch(() => null)) as {
44
- binding?: string;
45
- assertion?: AuthenticationResponseJSON;
46
- } | null;
46
+ const body = (await request.json().catch(() => null)) as
47
+ | ({
48
+ binding?: string;
49
+ } & PresenceProof)
50
+ | null;
47
51
  const parsed = parseAnswer(body);
48
52
  // A demo keeps no visitor's words where the next visitor would read them.
49
53
  const answer =
@@ -73,9 +77,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
73
77
  { status: 400 }
74
78
  );
75
79
  if (!demo && PRESENCE_KINDS.includes(ask.kind))
76
- await verifyPresence(
77
- relyingParty(request.headers),
78
- body.assertion,
80
+ await requirePresence(
81
+ request,
82
+ gate.approver,
83
+ body,
79
84
  "approve",
80
85
  askPresence(ask.id, body.binding)
81
86
  );
@@ -1,10 +1,12 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
2
  import { getWorkspace } from "@/lib/workspaces";
4
3
  import { setPaused } from "@/lib/orchestrator/pause";
5
4
  import { requireApprover } from "@/lib/security/approver";
6
- import { relyingParty, verifyPresence } from "@/lib/security/presence";
7
- import { refusalResponse } from "@/lib/security/presence-http";
5
+ import {
6
+ refusalResponse,
7
+ requirePresence,
8
+ type PresenceProof,
9
+ } from "@/lib/security/presence-http";
8
10
  import { resumePresence } from "@/lib/orchestrator/presence-binding";
9
11
 
10
12
  type RouteParams = { params: Promise<{ id: string }> };
@@ -18,10 +20,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
18
20
  const { id } = await params;
19
21
  if (!getWorkspace(id))
20
22
  return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
21
- const { paused, assertion } = (await request.json().catch(() => ({}))) as {
23
+ const body = (await request.json().catch(() => ({}))) as {
22
24
  paused?: unknown;
23
- assertion?: AuthenticationResponseJSON;
24
- };
25
+ } & PresenceProof;
26
+ const { paused } = body;
25
27
  if (typeof paused !== "boolean")
26
28
  return NextResponse.json(
27
29
  { error: "paused must be true or false" },
@@ -29,9 +31,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
29
31
  );
30
32
  try {
31
33
  if (!paused)
32
- await verifyPresence(
33
- relyingParty(request.headers),
34
- assertion,
34
+ await requirePresence(
35
+ request,
36
+ gate.approver,
37
+ body,
35
38
  "resume",
36
39
  resumePresence(id)
37
40
  );
@@ -1,11 +1,11 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
2
+ import type { PresenceProof } from "@/lib/security/presence-http";
3
3
  import { removeRule, updateRule } from "@/lib/orchestrator/rules";
4
4
  import { provenAsSaad } from "../presence";
5
5
 
6
6
  type RouteParams = { params: Promise<{ id: string; ruleId: string }> };
7
7
 
8
- type Body = { text?: unknown; assertion?: AuthenticationResponseJSON };
8
+ type Body = { text?: unknown } & PresenceProof;
9
9
 
10
10
  // The rule, its body, and Saad's passkey on it; or the refusal.
11
11
  async function target(
@@ -21,7 +21,7 @@ async function target(
21
21
  response: NextResponse.json({ error: "Bad rule id" }, { status: 400 }),
22
22
  };
23
23
  const body = (await request.json().catch(() => ({}))) as Body;
24
- const refused = await provenAsSaad(request, id, body.assertion);
24
+ const refused = await provenAsSaad(request, id, body);
25
25
  if (refused) return { response: refused };
26
26
  return { workspaceId: id, ruleId: n, body };
27
27
  }
@@ -1,8 +1,10 @@
1
1
  import type { NextRequest } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
2
  import { requireApprover } from "@/lib/security/approver";
4
- import { relyingParty, verifyPresence } from "@/lib/security/presence";
5
- import { refusalResponse } from "@/lib/security/presence-http";
3
+ import {
4
+ refusalResponse,
5
+ requirePresence,
6
+ type PresenceProof,
7
+ } from "@/lib/security/presence-http";
6
8
  import { rulesPresence } from "@/lib/orchestrator/presence-binding";
7
9
 
8
10
  // A rule written here is Saad's, shown to the orchestrator as his word and
@@ -11,14 +13,15 @@ import { rulesPresence } from "@/lib/orchestrator/presence-binding";
11
13
  export async function provenAsSaad(
12
14
  request: NextRequest,
13
15
  workspaceId: string,
14
- assertion: AuthenticationResponseJSON | undefined
16
+ proof: PresenceProof | undefined
15
17
  ): Promise<Response | null> {
16
18
  const gate = requireApprover(request);
17
19
  if (!gate.ok) return gate.response;
18
20
  try {
19
- await verifyPresence(
20
- relyingParty(request.headers),
21
- assertion,
21
+ await requirePresence(
22
+ request,
23
+ gate.approver,
24
+ proof,
22
25
  "rules",
23
26
  rulesPresence(workspaceId)
24
27
  );
@@ -1,5 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
2
+ import type { PresenceProof } from "@/lib/security/presence-http";
3
3
  import { getWorkspace } from "@/lib/workspaces";
4
4
  import { addRule, listRules } from "@/lib/orchestrator/rules";
5
5
  import { provenAsSaad } from "./presence";
@@ -19,13 +19,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
19
19
  const { id } = await params;
20
20
  if (!getWorkspace(id))
21
21
  return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
22
- const { text, assertion } = (await request.json().catch(() => ({}))) as {
22
+ const body = (await request.json().catch(() => ({}))) as {
23
23
  text?: unknown;
24
- assertion?: AuthenticationResponseJSON;
25
- };
24
+ } & PresenceProof;
25
+ const { text } = body;
26
26
  if (typeof text !== "string")
27
27
  return NextResponse.json({ error: "text is required" }, { status: 400 });
28
- const refused = await provenAsSaad(request, id, assertion);
28
+ const refused = await provenAsSaad(request, id, body);
29
29
  if (refused) return refused;
30
30
  try {
31
31
  return NextResponse.json({ rule: addRule(id, text, "saad") });
@@ -25,7 +25,8 @@ const when = (s: string) =>
25
25
  const fail = (e: Error) => toast.error(e.message);
26
26
 
27
27
  // Passkeys prove it's you approving an ask or resuming an orchestrator.
28
- // Each belongs to the host name it was made on.
28
+ // A browser passkey belongs to the host name it was made on; the app keeps its
29
+ // own key in the phone's Secure Enclave.
29
30
  export function PasskeysSection({ open }: { open: boolean }) {
30
31
  const { data } = usePasskeysQuery(open);
31
32
  const add = useAddPasskey();
@@ -64,7 +65,8 @@ export function PasskeysSection({ open }: { open: boolean }) {
64
65
  <div className="min-w-0 flex-1">
65
66
  <p className="truncate text-sm font-medium">{k.name}</p>
66
67
  <p className="text-muted-foreground truncate text-xs">
67
- {k.rp_id} · added {when(k.created_at)}
68
+ {k.type === "device" ? "Device key (Face ID)" : k.rp_id} · added{" "}
69
+ {when(k.created_at)}
68
70
  {k.registered_from ? ` from ${k.registered_from}` : ""} · used{" "}
69
71
  {lastSeen(k.last_used_at)}
70
72
  </p>
@@ -0,0 +1,90 @@
1
+ {
2
+ "1": "f6d5756a104c16ec",
3
+ "2": "3699f5f3cd215455",
4
+ "3": "dc4efbc29c2976ef",
5
+ "4": "d3db3af92f123a13",
6
+ "5": "d5937aba3b590fed",
7
+ "6": "0c4668cf9a58cecd",
8
+ "7": "7f535265ae1ad37f",
9
+ "8": "5c24f600f5f73145",
10
+ "9": "99496923fc7ff333",
11
+ "10": "22162670e68726c0",
12
+ "11": "21cd323c1654f9a9",
13
+ "12": "d66b982154dc3ee2",
14
+ "13": "ba1c8c4e2486a74d",
15
+ "14": "2f9575eed78d3284",
16
+ "15": "4c2293b3766c0d84",
17
+ "16": "8e798e52aed97875",
18
+ "17": "a743501f00312926",
19
+ "18": "cb84130b195bfa63",
20
+ "19": "b0ba5d304ca64aba",
21
+ "20": "ef3e125d28bfa506",
22
+ "21": "542664a9bef5f215",
23
+ "22": "195a6a7a87776269",
24
+ "23": "e4b50e60ac0821fa",
25
+ "24": "afb9644f31cc7602",
26
+ "25": "52bb47e1eed4e8d4",
27
+ "26": "9b5247315a01bdbe",
28
+ "27": "9c34c46d26c93cc4",
29
+ "28": "83d816f925aa0aba",
30
+ "29": "5ce556a828f7510b",
31
+ "30": "3d1bc245c30dcf49",
32
+ "31": "250433b280fd8df1",
33
+ "32": "95ec3c8125969681",
34
+ "33": "711226e6a027f6cd",
35
+ "34": "d8c917aa6f3e00b6",
36
+ "35": "7db35660583c2a50",
37
+ "36": "7ff8363149a3952c",
38
+ "37": "b7e72a18d3398da6",
39
+ "38": "cea02e00d1262601",
40
+ "39": "61c77d0f357d71d6",
41
+ "40": "6cedcf70268ddd25",
42
+ "41": "3d9af0548ae91946",
43
+ "42": "410ef4562a046ce6",
44
+ "43": "b0fca4e05ff6499e",
45
+ "44": "06b7f4dc636c9675",
46
+ "45": "73948f256c522959",
47
+ "46": "202f8fd102ef2581",
48
+ "47": "510435d6afa487cd",
49
+ "48": "10727869e8c8dea1",
50
+ "49": "0384a86395f6650a",
51
+ "50": "cc05c3663ca18b2b",
52
+ "51": "b381268fe096fb02",
53
+ "52": "281ad10453d9177a",
54
+ "53": "a7a53074ed49a566",
55
+ "54": "2358b106da3e7b68",
56
+ "55": "478ece72deb01953",
57
+ "56": "d04bafcaa2eda941",
58
+ "57": "0f9bc09a7a31f64d",
59
+ "58": "ccbf7eebccadfa3f",
60
+ "59": "1859ac87155d546b",
61
+ "60": "035e204a4b6c457e",
62
+ "61": "85d9bb65b051409a",
63
+ "62": "723d9c60994dec97",
64
+ "63": "77dd17570476ee17",
65
+ "64": "de5f368a88af93de",
66
+ "65": "081cc6eaf666581b",
67
+ "66": "08eedfefd3d3f3f9",
68
+ "67": "e37ead69c14e2d01",
69
+ "68": "9ab2551e258f1588",
70
+ "69": "55edb197ed55afe1",
71
+ "70": "d8a4faf39318bd1d",
72
+ "71": "45b9b9ff57d2083d",
73
+ "72": "51b77e2e1c5c8ae6",
74
+ "73": "2878b9f0762ab53d",
75
+ "74": "c94cd8f644a8518f",
76
+ "75": "e082428a046f9e6b",
77
+ "76": "17f3160630d658dc",
78
+ "77": "cd54bfd73fbcd168",
79
+ "78": "8ad44a982b1eb58d",
80
+ "79": "0b09d57d6ea8ce0a",
81
+ "80": "941d85f2ef8bd012",
82
+ "81": "ecd85de2bdf45ff1",
83
+ "82": "07f11cdac5f8444a",
84
+ "83": "eabf72b6a8616eac",
85
+ "84": "1ed4fb30b7435f28",
86
+ "85": "a85ac25c0e34f2fb",
87
+ "86": "50b5725a67286dee",
88
+ "87": "6024c62bcdbb186e",
89
+ "88": "bb58ec622ae398e5"
90
+ }
@@ -10,14 +10,43 @@ import {
10
10
  import type Database from "better-sqlite3";
11
11
  import { moveSchedulesToRecipes } from "./schedules-move";
12
12
 
13
- interface Migration {
13
+ // HOW TO ADD A MIGRATION: append an object with a timestamp `key`, never an
14
+ // integer `id`: `key: "YYYYMMDDTHHMM_snake_name"` (UTC, e.g.
15
+ // "20261014T0130_share_links"). Hand-picked numbers collide when two tasks run
16
+ // in parallel and the loser is silently skipped on databases that already
17
+ // applied the winner. Ids 1..88 are legacy: they run first, by number, and
18
+ // never change. Timestamp keys run after them, in lexical order. Never edit a
19
+ // migration that has shipped; add a new one. lib/db/migrations.test.ts
20
+ // enforces all of this.
21
+ interface LegacyMigration {
14
22
  id: number;
23
+ key?: undefined;
15
24
  name: string;
16
25
  up: (db: Database.Database) => void;
17
26
  }
27
+ interface KeyedMigration {
28
+ key: string;
29
+ id?: undefined;
30
+ name?: undefined;
31
+ up: (db: Database.Database) => void;
32
+ }
33
+ export type Migration = LegacyMigration | KeyedMigration;
34
+
35
+ export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
36
+ const migrationName = (m: Migration): string =>
37
+ m.name ?? (m.key as string).replace(/^\d{8}T\d{4}_/, "");
38
+
39
+ // Legacy ids sort before every timestamp key.
40
+ function compareToUpTo(m: Migration, upTo: number | string): number {
41
+ if (typeof upTo === "number") {
42
+ return m.id === undefined ? 1 : m.id - upTo;
43
+ }
44
+ if (m.id !== undefined) return -1;
45
+ return m.key < upTo ? -1 : m.key > upTo ? 1 : 0;
46
+ }
18
47
 
19
48
  // All migrations in order - never modify existing ones, only add new
20
- const migrations: Migration[] = [
49
+ export const migrations: Migration[] = [
21
50
  {
22
51
  id: 1,
23
52
  name: "add_group_path_to_sessions",
@@ -2137,12 +2166,34 @@ const migrations: Migration[] = [
2137
2166
  `);
2138
2167
  },
2139
2168
  },
2169
+ {
2170
+ id: 89,
2171
+ name: "passkeys_device_keys",
2172
+ up: (db) => {
2173
+ // A paired device's own approval key (Secure Enclave, Face ID) sits in
2174
+ // the passkeys table beside the browser passkeys: type 'device', bound
2175
+ // to the device that made it. Additive; existing rows read as webauthn.
2176
+ const columns = db.prepare(`PRAGMA table_info(passkeys)`).all() as {
2177
+ name: string;
2178
+ }[];
2179
+ if (!columns.some((c) => c.name === "type"))
2180
+ db.exec(
2181
+ `ALTER TABLE passkeys ADD COLUMN type TEXT NOT NULL DEFAULT 'webauthn'`
2182
+ );
2183
+ if (!columns.some((c) => c.name === "device_id"))
2184
+ db.exec(`ALTER TABLE passkeys ADD COLUMN device_id TEXT`);
2185
+ db.exec(
2186
+ `CREATE INDEX IF NOT EXISTS idx_passkeys_device ON passkeys(device_id)`
2187
+ );
2188
+ },
2189
+ },
2140
2190
  ];
2141
2191
 
2142
- // `upTo`: stop after this id (tests that start from an older database).
2192
+ // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
2193
+ // start from an older database).
2143
2194
  export function runMigrations(
2144
2195
  db: Database.Database,
2145
- upTo = Number.POSITIVE_INFINITY
2196
+ upTo?: number | string
2146
2197
  ): void {
2147
2198
  // Create migrations tracking table
2148
2199
  db.exec(`
@@ -2152,31 +2203,59 @@ export function runMigrations(
2152
2203
  applied_at TEXT NOT NULL DEFAULT (datetime('now'))
2153
2204
  )
2154
2205
  `);
2206
+ // Timestamp-keyed rows get an auto id and carry their key here; legacy rows
2207
+ // (key NULL) keep meaning "applied" by their integer id.
2208
+ const cols = db.prepare(`PRAGMA table_info(_migrations)`).all() as {
2209
+ name: string;
2210
+ }[];
2211
+ if (!cols.some((c) => c.name === "key")) {
2212
+ try {
2213
+ db.exec(`ALTER TABLE _migrations ADD COLUMN key TEXT`);
2214
+ } catch (error) {
2215
+ if (!String(error).includes("duplicate column")) throw error;
2216
+ }
2217
+ }
2218
+ db.exec(
2219
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_key ON _migrations(key)`
2220
+ );
2155
2221
 
2156
- // Get already applied migrations
2157
2222
  const applied = new Set(
2158
- (db.prepare(`SELECT id FROM _migrations`).all() as { id: number }[]).map(
2159
- (r) => r.id
2160
- )
2223
+ (
2224
+ db.prepare(`SELECT id, key FROM _migrations`).all() as {
2225
+ id: number;
2226
+ key: string | null;
2227
+ }[]
2228
+ ).map((r) => r.key ?? String(r.id))
2161
2229
  );
2162
2230
 
2163
2231
  // Use INSERT OR IGNORE to handle concurrent workers
2164
- const insertMigration = db.prepare(
2232
+ const insertLegacy = db.prepare(
2165
2233
  `INSERT OR IGNORE INTO _migrations (id, name) VALUES (?, ?)`
2166
2234
  );
2235
+ const insertKeyed = db.prepare(
2236
+ `INSERT OR IGNORE INTO _migrations (key, name) VALUES (?, ?)`
2237
+ );
2238
+ const record = (m: Migration) =>
2239
+ m.key === undefined
2240
+ ? insertLegacy.run(m.id, m.name)
2241
+ : insertKeyed.run(m.key, migrationName(m));
2167
2242
 
2168
2243
  for (const migration of migrations) {
2169
- if (applied.has(migration.id) || migration.id > upTo) continue;
2244
+ const key = migrationKey(migration);
2245
+ const name = migrationName(migration);
2246
+ if (
2247
+ applied.has(key) ||
2248
+ (upTo !== undefined && compareToUpTo(migration, upTo) > 0)
2249
+ )
2250
+ continue;
2170
2251
 
2171
2252
  try {
2172
2253
  migration.up(db);
2173
- const result = insertMigration.run(migration.id, migration.name);
2254
+ const result = record(migration);
2174
2255
  if (result.changes > 0) {
2175
- console.log(`Migration ${migration.id}: ${migration.name} applied`);
2256
+ console.log(`Migration ${key}: ${name} applied`);
2176
2257
  } else {
2177
- console.log(
2178
- `Migration ${migration.id}: ${migration.name} skipped (concurrent apply)`
2179
- );
2258
+ console.log(`Migration ${key}: ${name} skipped (concurrent apply)`);
2180
2259
  }
2181
2260
  } catch (error) {
2182
2261
  // Some migrations may fail if columns already exist (from old system or concurrent worker)
@@ -2186,15 +2265,10 @@ export function runMigrations(
2186
2265
  errorMsg.includes("duplicate column") ||
2187
2266
  errorMsg.includes("already exists")
2188
2267
  ) {
2189
- insertMigration.run(migration.id, migration.name);
2190
- console.log(
2191
- `Migration ${migration.id}: ${migration.name} skipped (already applied)`
2192
- );
2268
+ record(migration);
2269
+ console.log(`Migration ${key}: ${name} skipped (already applied)`);
2193
2270
  } else {
2194
- console.error(
2195
- `Migration ${migration.id}: ${migration.name} failed:`,
2196
- error
2197
- );
2271
+ console.error(`Migration ${key}: ${name} failed:`, error);
2198
2272
  throw error;
2199
2273
  }
2200
2274
  }
@@ -3,8 +3,11 @@
3
3
  // Declining a new passkey's ask revokes it.
4
4
 
5
5
  import type { PasskeyRow } from "../security/passkeys";
6
+ import { revokeDeviceKeys } from "../security/device-keys";
7
+ import { revokeDevice } from "../security/devices";
6
8
  import {
7
9
  AskRefused,
10
+ resolvePasskeyAsks,
8
11
  passkeySubject,
9
12
  raiseAsk,
10
13
  revokedPasskeySubject,
@@ -54,3 +57,14 @@ export function raisePasskeyAsks(
54
57
  }
55
58
  return n;
56
59
  }
60
+
61
+ // Signing a device out takes its approval key with it, and the revoke goes
62
+ // on the asks list like any other.
63
+ export function revokeDeviceAndKeys(deviceId: string, place: string): boolean {
64
+ const changed = revokeDevice(deviceId);
65
+ for (const key of revokeDeviceKeys(deviceId)) {
66
+ resolvePasskeyAsks(key.id, "revoked");
67
+ raisePasskeyAsks(key, place, "revoked");
68
+ }
69
+ return changed;
70
+ }
@@ -0,0 +1,27 @@
1
+ // What a paired device signs to prove a person is there. Client-safe (no
2
+ // database, no node modules), so the app builds the same bytes the server
3
+ // checks. The server rebuilds it from its own record of the challenge; what
4
+ // the client says it signed is never used.
5
+
6
+ export const DEVICE_KEY_DOMAIN = "AgentOS device approval v1";
7
+
8
+ export interface DeviceKeyMessage {
9
+ challenge: string;
10
+ purpose: string;
11
+ binding: string;
12
+ deviceId: string;
13
+ }
14
+
15
+ export function deviceKeyMessage(m: DeviceKeyMessage): string {
16
+ return [
17
+ DEVICE_KEY_DOMAIN,
18
+ m.challenge,
19
+ m.purpose,
20
+ m.binding,
21
+ m.deviceId,
22
+ ].join("\n");
23
+ }
24
+
25
+ // A device key's relying-party id in the passkeys table. It can never equal
26
+ // a host name, so the browser flow never sees these keys.
27
+ export const deviceRp = (deviceId: string) => `device:${deviceId}`;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A paired device's approval key: P-256, made in the phone's Secure Enclave
3
+ * and unlocked by Face ID, Touch ID or the passcode. Only the public key is
4
+ * kept, in the passkeys table (type 'device', rp_id device:<id>).
5
+ */
6
+
7
+ import crypto from "crypto";
8
+ import { getDb } from "@/lib/db";
9
+ import type { PasskeyRow } from "./passkeys";
10
+
11
+ // The fixed prefix of a P-256 SubjectPublicKeyInfo; the 65-byte X9.63 point
12
+ // follows it.
13
+ const SPKI_P256_PREFIX = Buffer.from(
14
+ "3059301306072a8648ce3d020106082a8648ce3d030107034200",
15
+ "hex"
16
+ );
17
+
18
+ export const newDeviceKeyId = () =>
19
+ crypto.randomBytes(16).toString("base64url");
20
+
21
+ // The app sends the key as an uncompressed X9.63 point. Throws unless it's a
22
+ // real point on the curve.
23
+ export function spkiFromRawP256(raw: Buffer): Buffer {
24
+ if (raw.length !== 65 || raw[0] !== 0x04)
25
+ throw new Error("Not a P-256 public key");
26
+ const spki = Buffer.concat([SPKI_P256_PREFIX, raw]);
27
+ crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
28
+ return spki;
29
+ }
30
+
31
+ // iOS signs with ecdsaSignatureMessageX962SHA256, which is DER.
32
+ export function verifyDeviceSignature(
33
+ spki: Buffer,
34
+ message: string,
35
+ signature: Buffer
36
+ ): boolean {
37
+ try {
38
+ return crypto.verify(
39
+ "sha256",
40
+ Buffer.from(message),
41
+ { key: spki, format: "der", type: "spki", dsaEncoding: "der" },
42
+ signature
43
+ );
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function activeDeviceKey(deviceId: string): PasskeyRow | null {
50
+ return (
51
+ (getDb()
52
+ .prepare(
53
+ `SELECT * FROM passkeys WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
54
+ )
55
+ .get(deviceId) as PasskeyRow | undefined) ?? null
56
+ );
57
+ }
58
+
59
+ // Revokes every active key of this device; returns them so the caller can
60
+ // raise the "revoked" asks.
61
+ export function revokeDeviceKeys(deviceId: string): PasskeyRow[] {
62
+ const keys = getDb()
63
+ .prepare(
64
+ `SELECT * FROM passkeys WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
65
+ )
66
+ .all(deviceId) as PasskeyRow[];
67
+ if (keys.length)
68
+ getDb()
69
+ .prepare(
70
+ `UPDATE passkeys SET revoked_at = datetime('now')
71
+ WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
72
+ )
73
+ .run(deviceId);
74
+ return keys;
75
+ }
@@ -19,12 +19,15 @@ export interface PasskeyRow {
19
19
  created_at: string;
20
20
  last_used_at: string | null;
21
21
  revoked_at: string | null;
22
+ // 'webauthn' (a browser passkey) or 'device' (a paired device's own key).
23
+ type: "webauthn" | "device";
24
+ device_id: string | null;
22
25
  }
23
26
 
24
27
  export type PasskeyView = Omit<PasskeyRow, "public_key" | "counter">;
25
28
 
26
29
  const VIEW = `id, transports, rp_id, name, registered_via, registered_from,
27
- user_agent, created_at, last_used_at, revoked_at`;
30
+ user_agent, created_at, last_used_at, revoked_at, type, device_id`;
28
31
 
29
32
  export function listPasskeys(): PasskeyView[] {
30
33
  return getDb()
@@ -66,12 +69,14 @@ export function addPasskey(p: {
66
69
  via: string;
67
70
  from: string | null;
68
71
  userAgent: string | null;
72
+ type?: "webauthn" | "device";
73
+ deviceId?: string | null;
69
74
  }): PasskeyRow {
70
75
  getDb()
71
76
  .prepare(
72
77
  `INSERT INTO passkeys (id, public_key, counter, transports, rp_id, name,
73
- registered_via, registered_from, user_agent)
74
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
78
+ registered_via, registered_from, user_agent, type, device_id)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
75
80
  )
76
81
  .run(
77
82
  p.id,
@@ -82,7 +87,9 @@ export function addPasskey(p: {
82
87
  p.name.trim().slice(0, 80) || "Passkey",
83
88
  p.via,
84
89
  p.from,
85
- p.userAgent?.slice(0, 300) ?? null
90
+ p.userAgent?.slice(0, 300) ?? null,
91
+ p.type ?? "webauthn",
92
+ p.deviceId ?? null
86
93
  );
87
94
  return getPasskey(p.id)!;
88
95
  }
@@ -5,7 +5,17 @@ import { NextResponse } from "next/server";
5
5
  import { REMOTE_HEADER } from "./auth";
6
6
  import type { Approver } from "./approver";
7
7
  import { getDevice } from "./devices";
8
- import { PresenceError } from "./presence";
8
+ import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
9
+ import type { NextRequest } from "next/server";
10
+ import type { PasskeyRow } from "./passkeys";
11
+ import {
12
+ PresenceError,
13
+ relyingParty,
14
+ verifyDevicePresence,
15
+ verifyPresence,
16
+ type DeviceSignature,
17
+ type PresencePurpose,
18
+ } from "./presence";
9
19
 
10
20
  export function placeOf(approver: Approver, headers: Headers): string {
11
21
  if (approver.via === "loopback") return "this machine";
@@ -14,6 +24,44 @@ export function placeOf(approver: Approver, headers: Headers): string {
14
24
  return getDevice(approver.deviceId ?? "")?.name ?? "a paired device";
15
25
  }
16
26
 
27
+ // What a request sends to prove a person: a browser's passkey assertion, or
28
+ // a paired device's signature from its own key.
29
+ export interface PresenceProof {
30
+ assertion?: AuthenticationResponseJSON;
31
+ deviceSignature?: DeviceSignature;
32
+ }
33
+
34
+ // A device signature counts only from the device whose key it is; anything
35
+ // else goes through the browser passkey check as before.
36
+ export function requirePresence(
37
+ request: NextRequest,
38
+ approver: Approver,
39
+ proof: PresenceProof | null | undefined,
40
+ purpose: PresencePurpose,
41
+ binding: string
42
+ ): Promise<PasskeyRow> {
43
+ if (proof?.deviceSignature) {
44
+ if (approver.via !== "device" || !approver.deviceId)
45
+ return Promise.reject(
46
+ new PresenceError("Only a paired device can sign with its own key")
47
+ );
48
+ return Promise.resolve().then(() =>
49
+ verifyDevicePresence(
50
+ approver.deviceId!,
51
+ proof.deviceSignature,
52
+ purpose,
53
+ binding
54
+ )
55
+ );
56
+ }
57
+ return verifyPresence(
58
+ relyingParty(request.headers),
59
+ proof?.assertion,
60
+ purpose,
61
+ binding
62
+ );
63
+ }
64
+
17
65
  export function refusalResponse(error: unknown): NextResponse {
18
66
  if (error instanceof PresenceError)
19
67
  return NextResponse.json(
@@ -23,6 +23,14 @@ import {
23
23
  type RegistrationResponseJSON,
24
24
  } from "@simplewebauthn/server";
25
25
  import { getDb } from "@/lib/db";
26
+ import { deviceKeyMessage, deviceRp } from "./device-key-message";
27
+ import {
28
+ activeDeviceKey,
29
+ newDeviceKeyId,
30
+ revokeDeviceKeys,
31
+ spkiFromRawP256,
32
+ verifyDeviceSignature,
33
+ } from "./device-keys";
26
34
  import {
27
35
  activePasskeys,
28
36
  markBootstrapped,
@@ -319,3 +327,179 @@ export async function verifyRegistration(
319
327
  });
320
328
  })();
321
329
  }
330
+
331
+ // ---- A paired device's own key (Secure Enclave, Face ID) ----
332
+
333
+ export interface DeviceSignature {
334
+ keyId: string;
335
+ challenge: string;
336
+ signature: string;
337
+ }
338
+
339
+ export interface DeviceOptions {
340
+ challenge: string;
341
+ keyId: string;
342
+ message: string;
343
+ }
344
+
345
+ const newChallenge = () => crypto.randomBytes(32).toString("base64url");
346
+
347
+ // Options for a device-key assertion, or null when the device has no key.
348
+ export function deviceAssertionOptions(
349
+ deviceId: string,
350
+ purpose: PresencePurpose,
351
+ binding: string
352
+ ): DeviceOptions | null {
353
+ const key = activeDeviceKey(deviceId);
354
+ if (!key) return null;
355
+ const challenge = newChallenge();
356
+ storeChallenge(challenge, "assert", purpose, binding, deviceRp(deviceId));
357
+ return {
358
+ challenge,
359
+ keyId: key.id,
360
+ message: deviceKeyMessage({ challenge, purpose, binding, deviceId }),
361
+ };
362
+ }
363
+
364
+ // Checks a device's signature over exactly this challenge, purpose and
365
+ // binding, by that device's own active key. The message is rebuilt here
366
+ // from what the server issued.
367
+ export function verifyDevicePresence(
368
+ deviceId: string,
369
+ proof: DeviceSignature | null | undefined,
370
+ purpose: PresencePurpose,
371
+ binding: string
372
+ ): PasskeyRow {
373
+ if (!proof?.keyId || !proof.challenge || !proof.signature)
374
+ throw new PresenceError("This needs your Face ID or Touch ID");
375
+ const key = getPasskey(proof.keyId);
376
+ if (
377
+ !key ||
378
+ key.type !== "device" ||
379
+ key.revoked_at ||
380
+ key.device_id !== deviceId
381
+ )
382
+ throw new PresenceError("Unknown passkey");
383
+ // Spent before the signature is checked: one try per challenge.
384
+ takeChallenge(
385
+ proof.challenge,
386
+ "assert",
387
+ purpose,
388
+ binding,
389
+ deviceRp(deviceId)
390
+ );
391
+ const message = deviceKeyMessage({
392
+ challenge: proof.challenge,
393
+ purpose,
394
+ binding,
395
+ deviceId,
396
+ });
397
+ if (
398
+ !verifyDeviceSignature(
399
+ key.public_key,
400
+ message,
401
+ Buffer.from(proof.signature, "base64url")
402
+ )
403
+ )
404
+ throw new PresenceError("Passkey check failed");
405
+ touchPasskey(key.id, key.counter);
406
+ return key;
407
+ }
408
+
409
+ // Options for registering this device's key: a challenge the new key signs.
410
+ // Needs the same trust as a browser passkey (the free first one, or a code).
411
+ export function deviceRegistrationOptions(
412
+ deviceId: string,
413
+ enrollCode?: string
414
+ ): { challenge: string; message: string } {
415
+ const binding = registrationBinding(enrollCode);
416
+ const challenge = newChallenge();
417
+ storeChallenge(
418
+ challenge,
419
+ "register",
420
+ "register",
421
+ binding,
422
+ deviceRp(deviceId)
423
+ );
424
+ return {
425
+ challenge,
426
+ message: deviceKeyMessage({
427
+ challenge,
428
+ purpose: "register",
429
+ binding,
430
+ deviceId,
431
+ }),
432
+ };
433
+ }
434
+
435
+ export function verifyDeviceRegistration(
436
+ deviceId: string,
437
+ body: {
438
+ publicKey: string;
439
+ challenge: string;
440
+ signature: string;
441
+ name: string;
442
+ hardware?: string;
443
+ },
444
+ meta: { via: string; from: string | null; userAgent: string | null },
445
+ enrollCode?: string
446
+ ): { key: PasskeyRow; replaced: PasskeyRow[] } {
447
+ const binding = registrationBinding(enrollCode);
448
+ let spki: Buffer;
449
+ try {
450
+ spki = spkiFromRawP256(Buffer.from(body.publicKey, "base64url"));
451
+ } catch {
452
+ throw new PresenceError("Not a usable key");
453
+ }
454
+ const message = deviceKeyMessage({
455
+ challenge: body.challenge,
456
+ purpose: "register",
457
+ binding,
458
+ deviceId,
459
+ });
460
+ if (
461
+ !verifyDeviceSignature(
462
+ spki,
463
+ message,
464
+ Buffer.from(body.signature, "base64url")
465
+ )
466
+ )
467
+ throw new PresenceError("The new key couldn't sign the request");
468
+ return getDb().transaction(() => {
469
+ takeChallenge(
470
+ body.challenge,
471
+ "register",
472
+ "register",
473
+ binding,
474
+ deviceRp(deviceId)
475
+ );
476
+ if (binding === "bootstrap") {
477
+ if (passkeysBootstrapped())
478
+ throw new PresenceError(
479
+ "A passkey was added meanwhile; get a code from it"
480
+ );
481
+ markBootstrapped();
482
+ } else {
483
+ const used = getDb()
484
+ .prepare(
485
+ `UPDATE passkey_enrollments SET used_at = ? WHERE code_hash = ? AND used_at IS NULL`
486
+ )
487
+ .run(now(), hash(enrollCode!)).changes;
488
+ if (used !== 1) throw new PresenceError("That code was already used");
489
+ }
490
+ // A reinstall makes a new key; the old one stops counting.
491
+ const replaced = revokeDeviceKeys(deviceId);
492
+ const hardware = body.hardware === "software" ? " (software key)" : "";
493
+ const key = addPasskey({
494
+ id: newDeviceKeyId(),
495
+ publicKey: spki,
496
+ counter: 0,
497
+ rpId: deviceRp(deviceId),
498
+ name: `${body.name.trim().slice(0, 60) || "Device"}${hardware}`,
499
+ type: "device",
500
+ deviceId,
501
+ ...meta,
502
+ });
503
+ return { key, replaced };
504
+ })();
505
+ }
@@ -81,3 +81,24 @@ export function softAuthenticator(rpId = "localhost") {
81
81
  },
82
82
  };
83
83
  }
84
+
85
+ // Tests only: a software stand-in for the app's Secure Enclave key. The
86
+ // public key is the uncompressed X9.63 point the app sends; sign() gives a
87
+ // DER signature, as iOS does.
88
+ export function softDeviceKey() {
89
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
90
+ namedCurve: "P-256",
91
+ });
92
+ const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string };
93
+ return {
94
+ publicKey: b64url(
95
+ Buffer.concat([
96
+ Buffer.from([0x04]),
97
+ Buffer.from(jwk.x, "base64url"),
98
+ Buffer.from(jwk.y, "base64url"),
99
+ ])
100
+ ),
101
+ sign: (message: string) =>
102
+ b64url(crypto.sign("sha256", Buffer.from(message), privateKey)),
103
+ };
104
+ }
@@ -267,11 +267,16 @@ export function buildShelves(input: ShelfInput): Shelves {
267
267
 
268
268
  const top: SidebarRow[] = [];
269
269
  for (const row of rows) {
270
- // A quick change always sits under the orchestrator that makes it: it's
271
- // that orchestrator's to carry, never one that needs you.
272
- const owner = row.session.quick_of
273
- ? byId.get(row.session.quick_of)
274
- : undefined;
270
+ // A quick change sits under the orchestrator that makes it: it's that
271
+ // orchestrator's to carry, never one that needs you. Once merged it
272
+ // leaves for the shelves, so Done can take it.
273
+ const finished =
274
+ (row.task && isFinished(row.task)) ||
275
+ row.session.task_status === "merged";
276
+ const owner =
277
+ row.session.quick_of && !finished
278
+ ? byId.get(row.session.quick_of)
279
+ : undefined;
275
280
  if (owner) {
276
281
  (owner.quick ??= []).push(row);
277
282
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.561",
3
+ "version": "0.3.564",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"