@saadnvd1/agent-os 0.3.548 → 0.3.550

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.
@@ -7,10 +7,12 @@ import { MAX_CLAIM_BYTES, readCapped } from "@/lib/security/read-capped";
7
7
  import {
8
8
  claimResponseBody,
9
9
  claimSetsCookie,
10
+ isNativeClaim,
10
11
  } from "@/lib/security/claim-response";
11
12
 
12
13
  // POST /api/pair/claim - reachable without a token; trades a code for one.
13
- // A client that isn't a browser sends `token: true` to get it in the body too.
14
+ // A client that isn't a browser sends `token: true` to get it in the body too,
15
+ // and starts with Can approve: it held a one-time code, as a browser does.
14
16
  export async function POST(request: NextRequest) {
15
17
  const raw = await readCapped(request, MAX_CLAIM_BYTES);
16
18
  if (raw === null)
@@ -23,6 +25,10 @@ export async function POST(request: NextRequest) {
23
25
  }
24
26
  if (!body.code)
25
27
  return NextResponse.json({ error: "code required" }, { status: 400 });
28
+ const native = isNativeClaim({
29
+ wantsToken: body.token,
30
+ origin: request.headers.get("origin"),
31
+ });
26
32
  const result = claimPairing({
27
33
  code: body.code,
28
34
  name: body.name ?? "",
@@ -31,6 +37,7 @@ export async function POST(request: NextRequest) {
31
37
  request.headers.get(REMOTE_HEADER),
32
38
  request.headers.get("x-forwarded-for")
33
39
  ),
40
+ native,
34
41
  });
35
42
  if (!result.ok) {
36
43
  const status = result.error === "rate_limited" ? 429 : 400;
@@ -4,6 +4,8 @@ import { GitPullRequest } from "lucide-react";
4
4
  import type { SidebarRow } from "@/lib/sidebar/shelves";
5
5
  import { TASK_STATE } from "@/components/Tasks/task-state-labels";
6
6
  import { cn } from "@/lib/utils";
7
+ import { RowShell } from "./RowShell";
8
+ import { SessionRowMenu } from "./SessionRowMenu";
7
9
 
8
10
  // One of an orchestrator's quick changes, under it: its name, its PR and
9
11
  // where CI stands. It has no agent to open, so the row opens its PR.
@@ -14,45 +16,50 @@ export function QuickChangeRow({ row }: { row: SidebarRow }) {
14
16
  const label = !session.pr_number
15
17
  ? "Editing, no PR yet"
16
18
  : `PR #${session.pr_number}${state ? ` · ${state.label}` : ""}`;
19
+ const leading = (
20
+ <GitPullRequest
21
+ aria-hidden
22
+ className={cn(
23
+ "h-3.5 w-3.5 shrink-0",
24
+ state?.tone ?? "text-muted-foreground"
25
+ )}
26
+ />
27
+ );
17
28
  const body = (
18
29
  <>
19
- <GitPullRequest
20
- aria-hidden
30
+ <span className="text-foreground truncate text-[13px]">
31
+ {session.name}
32
+ </span>
33
+ <span
21
34
  className={cn(
22
- "h-3.5 w-3.5 shrink-0",
23
- state?.tone ?? "text-muted-foreground"
35
+ "mt-0.5 truncate text-xs",
36
+ session.pr_number && state ? state.tone : "text-muted-foreground"
24
37
  )}
25
- />
26
- <span className="flex min-w-0 flex-1 flex-col leading-tight">
27
- <span className="text-foreground truncate text-[13px]">
28
- {session.name}
29
- </span>
30
- <span
31
- className={cn(
32
- "mt-0.5 truncate text-xs",
33
- session.pr_number && state ? state.tone : "text-muted-foreground"
34
- )}
35
- >
36
- {label}
37
- </span>
38
+ >
39
+ {label}
38
40
  </span>
39
41
  </>
40
42
  );
41
- const frame =
42
- "ml-5 flex min-h-11 items-center gap-2 rounded-md px-2.5 py-1 md:min-h-9";
43
- return pr ? (
44
- <a
45
- href={pr}
46
- target="_blank"
47
- rel="noreferrer"
48
- title={`Quick change: ${session.name}\n${label}`}
49
- className={cn(frame, "hover:bg-muted/60 transition-colors")}
50
- >
51
- {body}
52
- </a>
53
- ) : (
54
- <div title={`Quick change: ${session.name}\n${label}`} className={frame}>
55
- {body}
43
+ const openPr = pr ? () => window.open(pr, "_blank") : undefined;
44
+ return (
45
+ <div className="ml-5">
46
+ <RowShell
47
+ title={`Quick change: ${session.name}\n${label}`}
48
+ onActivate={openPr}
49
+ onClick={openPr}
50
+ leading={leading}
51
+ menu={(kind) => (
52
+ <SessionRowMenu
53
+ session={session}
54
+ row={row}
55
+ kind={kind}
56
+ onRename={() => {}}
57
+ />
58
+ )}
59
+ menuLabel="Quick change actions"
60
+ >
61
+ {body}
62
+ </RowShell>
56
63
  </div>
57
64
  );
58
65
  }
@@ -0,0 +1,152 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Session } from "@/lib/db/types";
7
+ import type { SidebarRow } from "@/lib/sidebar/shelves";
8
+ import { RowProvider, type RowContextValue } from "./RowContext";
9
+ import { SessionRowMenu } from "./SessionRowMenu";
10
+
11
+ // Radix's focus handling needs a real browser; plain elements show what the
12
+ // menu offers.
13
+ vi.mock("@/components/ui/dropdown-menu", async (importOriginal) => {
14
+ const { createElement: h } = await import("react");
15
+ const part = (props: { children?: unknown; onClick?: () => void }) =>
16
+ h("div", { onClick: props.onClick }, props.children as never);
17
+ return {
18
+ ...(await importOriginal<object>()),
19
+ DropdownMenuItem: part,
20
+ DropdownMenuSeparator: part,
21
+ DropdownMenuSub: part,
22
+ DropdownMenuSubTrigger: part,
23
+ DropdownMenuSubContent: part,
24
+ };
25
+ });
26
+
27
+ (
28
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
29
+ ).IS_REACT_ACT_ENVIRONMENT = true;
30
+
31
+ let root: Root;
32
+ let host: HTMLElement;
33
+ beforeEach(() => {
34
+ host = document.createElement("div");
35
+ document.body.append(host);
36
+ root = createRoot(host);
37
+ });
38
+ afterEach(() => {
39
+ act(() => root.unmount());
40
+ host.remove();
41
+ });
42
+
43
+ const ctx: RowContextValue = {
44
+ summarizingSessionId: null,
45
+ projects: [],
46
+ projectNames: new Map(),
47
+ workspaceNames: new Map(),
48
+ orchestrators: [],
49
+ runningByWorkspace: new Map(),
50
+ queuedByWorkspace: new Map(),
51
+ rollups: new Map(),
52
+ orchestratorNames: new Map(),
53
+ orderedIds: () => [],
54
+ cardUrl: () => null,
55
+ onSelect: vi.fn(),
56
+ onRename: vi.fn(),
57
+ onFork: vi.fn(),
58
+ onSummarize: vi.fn(),
59
+ onDelete: vi.fn(),
60
+ onMoveToProject: vi.fn(),
61
+ onPin: vi.fn(),
62
+ onFresh: vi.fn(),
63
+ };
64
+
65
+ async function menuText(session: Partial<Session>) {
66
+ const full = { id: "s1", name: "Fix typo", ...session } as Session;
67
+ const row: SidebarRow = {
68
+ session: full,
69
+ status: undefined,
70
+ need: null,
71
+ unread: false,
72
+ working: false,
73
+ activeAt: 0,
74
+ workers: [],
75
+ };
76
+ await act(async () =>
77
+ root.render(
78
+ createElement(
79
+ QueryClientProvider,
80
+ { client: new QueryClient() },
81
+ createElement(
82
+ RowProvider,
83
+ { value: ctx },
84
+ createElement(SessionRowMenu, {
85
+ session: full,
86
+ row,
87
+ kind: "dropdown",
88
+ onRename: () => {},
89
+ })
90
+ )
91
+ )
92
+ )
93
+ );
94
+ return document.body.textContent ?? "";
95
+ }
96
+
97
+ describe("SessionRowMenu", () => {
98
+ it("gives a quick change Done, Archive and Delete", async () => {
99
+ const text = await menuText({
100
+ quick_of: "o1",
101
+ task_status: "running",
102
+ role: null,
103
+ });
104
+ expect(text).toContain("Done");
105
+ expect(text).toContain("Archive");
106
+ expect(text).toContain("Delete session");
107
+ });
108
+
109
+ it("gives an orchestrator neither Done nor Archive", async () => {
110
+ const text = await menuText({ role: "orchestrator" });
111
+ expect(text).not.toContain("Done");
112
+ expect(text).not.toContain("Archive");
113
+ });
114
+ });
115
+
116
+ describe("QuickChangeRow", () => {
117
+ it("has a row menu to finish it from", async () => {
118
+ const { QuickChangeRow } = await import("./QuickChangeRow");
119
+ const session = {
120
+ id: "q1",
121
+ name: "Fix typo",
122
+ quick_of: "o1",
123
+ task_status: "running",
124
+ } as Session;
125
+ await act(async () =>
126
+ root.render(
127
+ createElement(
128
+ QueryClientProvider,
129
+ { client: new QueryClient() },
130
+ createElement(
131
+ RowProvider,
132
+ { value: ctx },
133
+ createElement(QuickChangeRow, {
134
+ row: {
135
+ session,
136
+ status: undefined,
137
+ need: null,
138
+ unread: false,
139
+ working: false,
140
+ activeAt: 0,
141
+ workers: [],
142
+ },
143
+ })
144
+ )
145
+ )
146
+ )
147
+ );
148
+ expect(
149
+ host.querySelector('[aria-label="Quick change actions"]')
150
+ ).not.toBeNull();
151
+ });
152
+ });
@@ -148,6 +148,28 @@ export function SessionRowMenu({
148
148
  ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
149
149
  null;
150
150
 
151
+ // A quick change has no agent: no fork, fresh start, snooze or move, just
152
+ // its PR and the ways to finish it.
153
+ if (session.quick_of)
154
+ return (
155
+ <>
156
+ {session.pr_url && (
157
+ <Item onClick={() => window.open(session.pr_url!, "_blank")}>
158
+ <GitPullRequest className={icon} />
159
+ Open PR #{session.pr_number}
160
+ </Item>
161
+ )}
162
+ <FinishItems session={session} plain={plain} Item={Item} />
163
+ <Item
164
+ onClick={() => ctx.onDelete(session.id)}
165
+ className="text-red-500 focus:text-red-500"
166
+ >
167
+ <Trash2 className={icon} />
168
+ Delete session
169
+ </Item>
170
+ </>
171
+ );
172
+
151
173
  return (
152
174
  <>
153
175
  {session.branch_name && (
@@ -1994,6 +1994,22 @@ const migrations: Migration[] = [
1994
1994
  );
1995
1995
  },
1996
1996
  },
1997
+ {
1998
+ id: 83,
1999
+ name: "native_devices_can_approve",
2000
+ up: (db) => {
2001
+ // New native pairings start with Can approve; this gives it once to the
2002
+ // ones paired before. The claim wasn't recorded, so the app is known by
2003
+ // its user agent (`AgentOS/<build> CFNetwork/...` on iOS) or by an
2004
+ // app push token. It only ever turns the flag on.
2005
+ db.exec(
2006
+ `UPDATE devices SET can_approve = 1
2007
+ WHERE revoked_at IS NULL AND can_approve = 0
2008
+ AND (user_agent LIKE 'AgentOS/% CFNetwork/%'
2009
+ OR push_platform IN ('ios', 'android'))`
2010
+ );
2011
+ },
2012
+ },
1997
2013
  ];
1998
2014
 
1999
2015
  // `upTo`: stop after this id (tests that start from an older database).
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Who may answer the orchestrator's asks or pause it: this machine, the
3
- * tailnet, or a paired device Saad has let approve (off by default). This
3
+ * tailnet, or a paired device with Can approve (the native app gets it at
4
+ * pairing; Saad can turn it off or on in Devices). This
4
5
  * is the coarse gate; approvals that matter also need a passkey (presence).
5
6
  */
6
7
 
@@ -11,13 +11,19 @@ export interface ClaimBody {
11
11
  token?: string;
12
12
  }
13
13
 
14
+ // The native app asks for its token and sends no Origin; a browser always
15
+ // sends one on a POST, so a page can't pass for the app.
16
+ export const isNativeClaim = (request: {
17
+ wantsToken?: unknown;
18
+ origin: string | null;
19
+ }) => request.wantsToken === true && !request.origin;
20
+
14
21
  export function claimResponseBody(
15
22
  device: { id: string; name: string },
16
23
  token: string,
17
24
  request: { wantsToken?: unknown; origin: string | null }
18
25
  ): ClaimBody {
19
- const native = request.wantsToken === true && !request.origin;
20
- return { device, ...(native ? { token } : {}) };
26
+ return { device, ...(isNativeClaim(request) ? { token } : {}) };
21
27
  }
22
28
 
23
29
  // Whether the reply also sets the device cookie: only for a browser. A
@@ -20,7 +20,8 @@ export interface Device {
20
20
  last_seen_at: string | null;
21
21
  last_address: string | null;
22
22
  revoked_at: string | null;
23
- // Saad let it answer the orchestrator's asks (off by default).
23
+ // It may answer the orchestrator's asks: on for the native app at
24
+ // pairing, off for a browser; Saad can flip it either way in Devices.
24
25
  can_approve: number;
25
26
  }
26
27
 
@@ -32,19 +33,22 @@ export const hashToken = (token: string) =>
32
33
 
33
34
  export function mintDevice(
34
35
  name: string,
35
- userAgent?: string | null
36
+ userAgent?: string | null,
37
+ canApprove = false
36
38
  ): { device: Device; token: string } {
37
39
  const id = crypto.randomUUID();
38
40
  const token = TOKEN_PREFIX + crypto.randomBytes(32).toString("base64url");
39
41
  getDb()
40
42
  .prepare(
41
- `INSERT INTO devices (id, name, token_hash, user_agent) VALUES (?, ?, ?, ?)`
43
+ `INSERT INTO devices (id, name, token_hash, user_agent, can_approve)
44
+ VALUES (?, ?, ?, ?, ?)`
42
45
  )
43
46
  .run(
44
47
  id,
45
48
  name.trim().slice(0, 80) || "Device",
46
49
  hashToken(token),
47
- userAgent ?? null
50
+ userAgent ?? null,
51
+ canApprove ? 1 : 0
48
52
  );
49
53
  return { device: getDevice(id)!, token };
50
54
  }
@@ -82,6 +82,8 @@ export function claimPairing(
82
82
  name: string;
83
83
  userAgent?: string | null;
84
84
  address: string;
85
+ // The native app (claimResponseBody's test): it may approve from the start.
86
+ native?: boolean;
85
87
  },
86
88
  now = Date.now()
87
89
  ): ClaimResult {
@@ -103,7 +105,11 @@ export function claimPairing(
103
105
  const p = state.codes.get(normalizeCode(input.code));
104
106
  if (!p || p.claimed || p.expiresAt < now)
105
107
  return { ok: false, error: "invalid" };
106
- const { device, token } = mintDevice(input.name, input.userAgent);
108
+ const { device, token } = mintDevice(
109
+ input.name,
110
+ input.userAgent,
111
+ input.native === true
112
+ );
107
113
  p.claimed = { deviceId: device.id, name: device.name };
108
114
  return { ok: true, device, token };
109
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.548",
3
+ "version": "0.3.550",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"