@saadnvd1/agent-os 0.3.664 → 0.3.671

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 (73) hide show
  1. package/app/api/audits/[kind]/[id]/route.test.ts +2 -2
  2. package/app/api/mcp-auth/callback/route.test.ts +42 -0
  3. package/app/api/mcp-auth/callback/route.ts +56 -0
  4. package/app/api/projects/init/route.test.ts +2 -4
  5. package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
  6. package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
  7. package/app/api/shares/route.test.ts +3 -4
  8. package/app/api/workspaces/[id]/route.ts +9 -1
  9. package/app/api/worktree-sweep/route.test.ts +21 -0
  10. package/app/api/worktree-sweep/route.ts +24 -4
  11. package/components/Chat/ChatPanel.tsx +6 -3
  12. package/components/Chat/McpServers.tsx +61 -24
  13. package/components/Chat/McpSignIn.tsx +122 -0
  14. package/components/Chat/session.ts +8 -0
  15. package/components/Health/SweepRuns.tsx +1 -0
  16. package/components/Workspaces/WorkspaceSettings.tsx +6 -0
  17. package/data/chat/mcpAuth.ts +42 -0
  18. package/data/workspaces/index.ts +6 -0
  19. package/lib/chat/driver.ts +24 -2
  20. package/lib/chat/drivers/claude-approvals.ts +7 -0
  21. package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
  22. package/lib/chat/drivers/claude.ts +7 -0
  23. package/lib/chat/events.ts +12 -0
  24. package/lib/chat/mcp-auth.ts +193 -0
  25. package/lib/chat/registry.ts +2 -0
  26. package/lib/chat/runner.ts +18 -0
  27. package/lib/chat/worker/client.ts +35 -1
  28. package/lib/chat/worker/host.ts +9 -0
  29. package/lib/chat/worker/main.ts +1 -1
  30. package/lib/chat/worker/mcp-auth.ts +119 -0
  31. package/lib/chat/worker/protocol.ts +19 -0
  32. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  33. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  34. package/lib/db/types.ts +3 -0
  35. package/lib/done/testing.ts +2 -2
  36. package/lib/env-setup.ts +8 -1
  37. package/lib/health/boots.ts +2 -8
  38. package/lib/orchestrator/armed.test-helpers.ts +3 -2
  39. package/lib/orchestrator/brief.ts +14 -9
  40. package/lib/orchestrator/external-gates.ts +62 -1
  41. package/lib/orchestrator/full-access.ts +21 -0
  42. package/lib/orchestrator/quick.ts +47 -4
  43. package/lib/orchestrator/serve.ts +11 -2
  44. package/lib/orchestrator/shell-guard.ts +23 -1
  45. package/lib/orchestrator/signoff.test-helpers.ts +3 -2
  46. package/lib/orchestrator/sweep-arm.ts +180 -0
  47. package/lib/orchestrator/tool-names.ts +4 -1
  48. package/lib/orchestrator/tool-schemas.ts +33 -1
  49. package/lib/orchestrator/tools.ts +6 -2
  50. package/lib/orchestrator/write-guard.ts +34 -7
  51. package/lib/project-config/brief.ts +3 -1
  52. package/lib/project-config/database.ts +6 -0
  53. package/lib/project-config/index.ts +108 -21
  54. package/lib/project-config/schema.ts +10 -4
  55. package/lib/project-config/session.ts +9 -6
  56. package/lib/security/net.ts +7 -4
  57. package/lib/tasks/move-testing.ts +2 -2
  58. package/lib/test-temp.ts +30 -0
  59. package/lib/workspaces.ts +6 -2
  60. package/lib/worktree-sweep/caches.ts +6 -0
  61. package/lib/worktree-sweep/extras.ts +16 -8
  62. package/lib/worktree-sweep/report.ts +1 -0
  63. package/lib/worktree-sweep/settings.ts +2 -2
  64. package/lib/worktree-sweep/testTemp.ts +113 -0
  65. package/lib/worktree-sweep/types.ts +2 -0
  66. package/package.json +1 -1
  67. package/scripts/autodeploy.test.ts +2 -2
  68. package/scripts/check-bundle.test.ts +2 -2
  69. package/scripts/hooks/banned-terms.test.ts +3 -2
  70. package/scripts/hooks/staged-secrets.test.ts +2 -2
  71. package/scripts/lib/build-swap.test.ts +2 -3
  72. package/scripts/lib/wsl.test.ts +2 -3
  73. package/scripts/update-run.test.ts +2 -2
@@ -1,14 +1,14 @@
1
1
  import fs from "fs";
2
- import os from "os";
3
2
  import path from "path";
4
3
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
4
  import type { NextRequest } from "next/server";
6
5
  import { GET as list } from "../../route";
7
6
  import { GET } from "./route";
7
+ import { tempDir } from "@/lib/test-temp";
8
8
 
9
9
  let dir: string;
10
10
  beforeEach(() => {
11
- dir = fs.mkdtempSync(path.join(os.tmpdir(), "audits-route-"));
11
+ dir = tempDir("audits-route-");
12
12
  process.env.AGENTOS_AUDITS_DIR = dir;
13
13
  fs.mkdirSync(path.join(dir, "sessions"));
14
14
  fs.mkdirSync(path.join(dir, "gaps"));
@@ -0,0 +1,42 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const finish = vi.hoisted(() => vi.fn());
5
+ vi.mock("@/lib/chat/mcp-auth", () => ({ finishMcpSignInFromRedirect: finish }));
6
+
7
+ const { GET } = await import("./route");
8
+ const get = (query: string) =>
9
+ GET(new NextRequest(`http://localhost:3011/api/mcp-auth/callback?${query}`));
10
+
11
+ beforeEach(() => vi.clearAllMocks());
12
+
13
+ describe("GET /api/mcp-auth/callback", () => {
14
+ it("hands the query string over and says which server signed in, escaped", async () => {
15
+ finish.mockResolvedValue({ server: "<b>linear</b>" });
16
+ const res = await get("code=c&state=s");
17
+ const html = await res.text();
18
+ // Only the query string goes on: the origin comes from the pending sign-in
19
+ expect(finish).toHaveBeenCalledWith("?code=c&state=s");
20
+ expect(res.status).toBe(200);
21
+ expect(html).toContain("&lt;b&gt;linear&lt;/b&gt;");
22
+ expect(html).not.toContain("<b>linear");
23
+ expect(res.headers.get("cache-control")).toBe("no-store");
24
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
25
+ });
26
+
27
+ it("shows a refusal as a page, never throws", async () => {
28
+ finish.mockRejectedValue(new Error("That sign-in expired"));
29
+ const res = await get("code=c&state=s");
30
+ expect(res.status).toBe(400);
31
+ expect(await res.text()).toContain("That sign-in expired");
32
+ });
33
+
34
+ it("shows the provider's error escaped", async () => {
35
+ finish.mockResolvedValue({ server: "x" });
36
+ const res = await get("error=access_denied&error_description=%3Cscript%3E");
37
+ const html = await res.text();
38
+ expect(res.status).toBe(400);
39
+ expect(html).toContain("&lt;script&gt;");
40
+ expect(html).not.toContain("<script>");
41
+ });
42
+ });
@@ -0,0 +1,56 @@
1
+ import { NextRequest } from "next/server";
2
+ import { finishMcpSignInFromRedirect } from "@/lib/chat/mcp-auth";
3
+
4
+ const escape = (text: string) =>
5
+ text.replace(
6
+ /[&<>"']/g,
7
+ (c) =>
8
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
9
+ c
10
+ ] as string
11
+ );
12
+
13
+ function page(ok: boolean, heading: string, message: string): Response {
14
+ const body = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(heading)}</title><style>:root{color-scheme:light dark}body{font:16px system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;text-align:center}main{max-width:26rem}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:0;opacity:.75}</style></head><body><main><h1>${escape(heading)}</h1><p>${escape(message)}</p></main></body></html>`;
15
+ return new Response(body, {
16
+ status: ok ? 200 : 400,
17
+ headers: {
18
+ "Content-Type": "text/html; charset=utf-8",
19
+ "Cache-Control": "no-store",
20
+ "Referrer-Policy": "no-referrer",
21
+ },
22
+ });
23
+ }
24
+
25
+ // GET /api/mcp-auth/callback - where an MCP server's sign-in sends the
26
+ // browser back to. The address carries a one-time code, so nothing here
27
+ // logs or rethrows it.
28
+ export async function GET(request: NextRequest) {
29
+ const params = request.nextUrl.searchParams;
30
+ const failed = params.get("error");
31
+ try {
32
+ if (failed && !params.get("code")) {
33
+ const why = (params.get("error_description") || failed).slice(0, 200);
34
+ await finishMcpSignInFromRedirect(request.nextUrl.search).catch(() => {});
35
+ return page(
36
+ false,
37
+ "Sign-in wasn't completed",
38
+ `${why}. Press Sign in again in AgentOS.`
39
+ );
40
+ }
41
+ const { server } = await finishMcpSignInFromRedirect(
42
+ request.nextUrl.search
43
+ );
44
+ return page(
45
+ true,
46
+ `Signed in to ${server}`,
47
+ "You can close this tab and go back to AgentOS."
48
+ );
49
+ } catch (error) {
50
+ return page(
51
+ false,
52
+ "Sign-in wasn't completed",
53
+ error instanceof Error ? error.message : "Press Sign in again."
54
+ );
55
+ }
56
+ }
@@ -1,11 +1,9 @@
1
- import fs from "fs";
2
- import os from "os";
3
- import path from "path";
4
1
  import { describe, expect, it } from "vitest";
5
2
  import type { NextRequest } from "next/server";
6
3
  import { getProject } from "@/lib/projects";
7
4
  import { createWorkspace } from "@/lib/workspaces";
8
5
  import { POST } from "./route";
6
+ import { tempDir } from "@/lib/test-temp";
9
7
 
10
8
  const post = (body: object) =>
11
9
  POST({ json: async () => body } as unknown as NextRequest);
@@ -14,7 +12,7 @@ describe("POST /api/projects/init", () => {
14
12
  it("answers a folder that's already a project with 409 and it, and moves it on moveExisting", async () => {
15
13
  const a = createWorkspace("Route A");
16
14
  const b = createWorkspace("Route B");
17
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-init-route-"));
15
+ const dir = tempDir("aos-init-route-");
18
16
 
19
17
  const made = await post({ path: dir, workspaceId: a.id });
20
18
  expect(made.status).toBe(201);
@@ -0,0 +1,86 @@
1
+ import { NextRequest } from "next/server";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const auth = vi.hoisted(() => ({
5
+ start: vi.fn(),
6
+ finish: vi.fn(),
7
+ reconnect: vi.fn(),
8
+ }));
9
+ vi.mock("@/lib/chat/mcp-auth", () => ({
10
+ startMcpSignIn: auth.start,
11
+ finishMcpSignIn: auth.finish,
12
+ reconnectMcpServer: auth.reconnect,
13
+ }));
14
+
15
+ const { POST } = await import("./route");
16
+
17
+ const post = (body: unknown, headers: Record<string, string> = {}) =>
18
+ POST(
19
+ new NextRequest("http://x/api/sessions/s1/mcp-auth", {
20
+ method: "POST",
21
+ headers,
22
+ body: JSON.stringify(body),
23
+ }),
24
+ { params: Promise.resolve({ id: "s1" }) }
25
+ );
26
+ const base = { server: "linear", itemId: "mcp-1" };
27
+
28
+ beforeEach(() => vi.clearAllMocks());
29
+
30
+ describe("POST /api/sessions/[id]/mcp-auth", () => {
31
+ it("starts with the browser's own origin, never one from the body", async () => {
32
+ auth.start.mockResolvedValue({ mode: "callback", authUrl: "https://a/b" });
33
+ const res = await post(
34
+ { ...base, action: "start", origin: "https://evil.example" },
35
+ { origin: "https://mac.ts.net:3443" }
36
+ );
37
+ expect(await res.json()).toEqual({
38
+ mode: "callback",
39
+ authUrl: "https://a/b",
40
+ });
41
+ expect(auth.start).toHaveBeenCalledWith("s1", {
42
+ ...base,
43
+ origin: "https://mac.ts.net:3443",
44
+ });
45
+ });
46
+
47
+ it("finishes with a pasted address pinned to the row", async () => {
48
+ auth.finish.mockResolvedValue({ server: "linear" });
49
+ const res = await post({
50
+ ...base,
51
+ action: "submit",
52
+ url: " https://x/cb?code=1&state=2 ",
53
+ });
54
+ expect(res.status).toBe(200);
55
+ expect(auth.finish).toHaveBeenCalledWith("https://x/cb?code=1&state=2", {
56
+ sessionId: "s1",
57
+ server: "linear",
58
+ });
59
+ });
60
+
61
+ it("reconnects", async () => {
62
+ auth.reconnect.mockResolvedValue(undefined);
63
+ expect((await post({ ...base, action: "reconnect" })).status).toBe(200);
64
+ expect(auth.reconnect).toHaveBeenCalledWith("s1", base);
65
+ });
66
+
67
+ it("refuses a bad body without calling anything", async () => {
68
+ for (const body of [
69
+ null,
70
+ { action: "start" },
71
+ { ...base, action: "nope" },
72
+ { ...base, action: "submit" },
73
+ { ...base, server: "x".repeat(300), action: "start" },
74
+ ])
75
+ expect((await post(body)).status).toBe(400);
76
+ expect(auth.start).not.toHaveBeenCalled();
77
+ expect(auth.finish).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("answers a refusal with its reason", async () => {
81
+ auth.start.mockRejectedValue(new Error("This chat's agent isn't running."));
82
+ const res = await post({ ...base, action: "start" });
83
+ expect(res.status).toBe(409);
84
+ expect((await res.json()).error).toMatch(/isn't running/);
85
+ });
86
+ });
@@ -0,0 +1,58 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ finishMcpSignIn,
4
+ reconnectMcpServer,
5
+ startMcpSignIn,
6
+ } from "@/lib/chat/mcp-auth";
7
+
8
+ // POST /api/sessions/[id]/mcp-auth - sign in to an MCP server listed in a
9
+ // chat's /mcp item: start it (answers with the link to open), finish it with
10
+ // the address the provider ended on, or reconnect.
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const { id } = await params;
16
+ const body = (await request.json().catch(() => null)) as {
17
+ action?: unknown;
18
+ server?: unknown;
19
+ itemId?: unknown;
20
+ url?: unknown;
21
+ } | null;
22
+ const { action, server, itemId, url } = body ?? {};
23
+ if (
24
+ typeof server !== "string" ||
25
+ !server ||
26
+ server.length > 200 ||
27
+ typeof itemId !== "string" ||
28
+ !itemId ||
29
+ itemId.length > 200
30
+ )
31
+ return NextResponse.json({ error: "Bad request" }, { status: 400 });
32
+ try {
33
+ if (action === "start")
34
+ return NextResponse.json(
35
+ await startMcpSignIn(id, {
36
+ itemId,
37
+ server,
38
+ origin: request.headers.get("origin"),
39
+ })
40
+ );
41
+ if (action === "submit") {
42
+ if (typeof url !== "string" || !url || url.length > 4096)
43
+ return NextResponse.json({ error: "Bad request" }, { status: 400 });
44
+ await finishMcpSignIn(url.trim(), { sessionId: id, server });
45
+ return NextResponse.json({ ok: true });
46
+ }
47
+ if (action === "reconnect") {
48
+ await reconnectMcpServer(id, { itemId, server });
49
+ return NextResponse.json({ ok: true });
50
+ }
51
+ return NextResponse.json({ error: "Bad request" }, { status: 400 });
52
+ } catch (error) {
53
+ return NextResponse.json(
54
+ { error: error instanceof Error ? error.message : "Sign-in failed" },
55
+ { status: 409 }
56
+ );
57
+ }
58
+ }
@@ -6,9 +6,10 @@ import path from "path";
6
6
  import type { AddressInfo } from "net";
7
7
  import { randomUUID } from "crypto";
8
8
  import { afterAll, beforeEach, describe, expect, it } from "vitest";
9
+ import { tempDir } from "@/lib/test-temp";
9
10
 
10
11
  // A fake Connect that verifies each signed request against the machine's key.
11
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentos-share-route-"));
12
+ const dir = tempDir("agentos-share-route-");
12
13
  const keys = crypto.generateKeyPairSync("ed25519");
13
14
  const publicPem = keys.publicKey
14
15
  .export({ type: "spki", format: "pem" })
@@ -90,9 +91,7 @@ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
90
91
  process.env.AGENTOS_CONNECT_DIR = dir;
91
92
  process.env.CONNECT_API_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
92
93
  process.env.AGENTOS_CONNECT_OPERATOR = "1";
93
- process.env.AGENTOS_ARTIFACTS_DIR = fs.mkdtempSync(
94
- path.join(os.tmpdir(), "agentos-share-art-")
95
- );
94
+ process.env.AGENTOS_ARTIFACTS_DIR = tempDir("agentos-share-art-");
96
95
 
97
96
  const link = () => {
98
97
  fs.writeFileSync(
@@ -12,6 +12,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
12
12
  sortOrder,
13
13
  maxRunningTasks,
14
14
  wakeTasksOnPR,
15
+ orchFullAccess,
15
16
  mcpServers,
16
17
  } = await request.json();
17
18
  if (wakeTasksOnPR !== undefined && typeof wakeTasksOnPR !== "boolean")
@@ -19,6 +20,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
19
20
  { error: "wakeTasksOnPR is true or false" },
20
21
  { status: 400 }
21
22
  );
23
+ if (orchFullAccess !== undefined && typeof orchFullAccess !== "boolean")
24
+ return NextResponse.json(
25
+ { error: "orchFullAccess is true or false" },
26
+ { status: 400 }
27
+ );
22
28
  let workspace;
23
29
  try {
24
30
  workspace = updateWorkspace(id, {
@@ -27,6 +33,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
27
33
  sortOrder,
28
34
  maxRunningTasks,
29
35
  wakeTasksOnPR,
36
+ orchFullAccess,
30
37
  mcpServers,
31
38
  });
32
39
  } catch (error) {
@@ -36,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
36
43
  if (!workspace) {
37
44
  return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
38
45
  }
39
- if (mcpServers !== undefined) await applyMcpServers(id);
46
+ if (mcpServers !== undefined || orchFullAccess !== undefined)
47
+ await applyMcpServers(id);
40
48
  return NextResponse.json({ workspace });
41
49
  }
42
50
 
@@ -48,6 +48,27 @@ describe("/api/worktree-sweep", () => {
48
48
  expect((await (await GET()).json()).armed).toBe(false);
49
49
  });
50
50
 
51
+ it("lets a session turn it on only as an orchestrator with full access", async () => {
52
+ const { createWorkspace, updateWorkspace } =
53
+ await import("@/lib/workspaces");
54
+ const { ensureOrchestrator } = await import("@/lib/orchestrator/home");
55
+ const { db } = await import("@/lib/db");
56
+ const w = createWorkspace(`sw-${randomUUID().slice(0, 6)}`);
57
+ const o = ensureOrchestrator(w.id);
58
+ expect((await put({ armed: true, session: o.id })).status).toBe(200);
59
+ await put({ armed: false });
60
+ // A task agent's session, in the same workspace.
61
+ db.prepare(`UPDATE sessions SET role = NULL WHERE id = ?`).run(o.id);
62
+ expect((await put({ armed: true, session: o.id })).status).toBe(403);
63
+ db.prepare(`UPDATE sessions SET role = 'orchestrator' WHERE id = ?`).run(
64
+ o.id
65
+ );
66
+ updateWorkspace(w.id, { orchFullAccess: false });
67
+ expect((await put({ armed: true, session: o.id })).status).toBe(403);
68
+ expect((await put({ armed: true, session: "nobody" })).status).toBe(403);
69
+ expect((await (await GET()).json()).armed).toBe(false);
70
+ });
71
+
51
72
  it("refuses a number that isn't a whole 0-365", async () => {
52
73
  for (const days of [-1, 1.5, 400, "7", undefined]) {
53
74
  const res = await put({ days });
@@ -7,6 +7,8 @@ import {
7
7
  sweepDays,
8
8
  } from "@/lib/worktree-sweep/settings";
9
9
  import { requireLocalTrust } from "@/lib/security/route-guard";
10
+ import { db, type Session } from "@/lib/db";
11
+ import { orchestratorFullAccess } from "@/lib/orchestrator/full-access";
10
12
  import { sweepState } from "@/lib/worktree-sweep/state";
11
13
 
12
14
  const body = z.union([
@@ -15,7 +17,8 @@ const body = z.union([
15
17
  days: sweepDays.nullable(),
16
18
  projectId: z.string().min(1).optional(),
17
19
  }),
18
- z.object({ armed: z.boolean() }),
20
+ // `session`: the AgentOS session the `aos` command runs in, if any.
21
+ z.object({ armed: z.boolean(), session: z.string().min(1).optional() }),
19
22
  ]);
20
23
 
21
24
  export async function GET() {
@@ -33,12 +36,29 @@ export async function PUT(request: NextRequest) {
33
36
  { status: 400 }
34
37
  );
35
38
  if ("armed" in got.data) {
36
- // Turning the removal on is a person's call, made on this machine or
37
- // over Tailscale: a stolen device token must not be able to. Turning it
38
- // off stays on the ordinary gate.
39
+ // Turning the removal on is Saad's call, made on this machine or over
40
+ // Tailscale: a stolen device token must not be able to. The orchestrator
41
+ // arms through its sweep_arm tool (a linked machine over ssh to its own
42
+ // loopback), never with a device token. Turning it off stays on the
43
+ // ordinary gate.
39
44
  if (got.data.armed) {
40
45
  const refused = requireLocalTrust(request);
41
46
  if (refused) return refused;
47
+ // A session turns it on only as an orchestrator whose workspace has
48
+ // Orchestrator full access on.
49
+ if (got.data.session) {
50
+ const s = db
51
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
52
+ .get(got.data.session) as Session | undefined;
53
+ if (!s || !orchestratorFullAccess(s))
54
+ return NextResponse.json(
55
+ {
56
+ error:
57
+ "a session can't turn the full clean-up on: Saad does (Health, Clean-up, Turn on), or the orchestrator's sweep_arm tool when he has said to",
58
+ },
59
+ { status: 403 }
60
+ );
61
+ }
42
62
  }
43
63
  setReaperArmed(got.data.armed);
44
64
  return NextResponse.json(sweepState());
@@ -51,6 +51,7 @@ import { Background, type BackgroundValue } from "./background";
51
51
  import { FindBar } from "./FindBar";
52
52
  import { FIND_EVENT, isFindKey, useFind } from "./useFind";
53
53
  import { McpServers } from "./McpServers";
54
+ import { ChatSessionId } from "./session";
54
55
  import { ArtifactCard } from "./Artifact";
55
56
  import { PlanCard } from "./PlanCard";
56
57
  import { ChatIdle } from "./idle";
@@ -677,9 +678,11 @@ export function ChatPanel({
677
678
  </p>
678
679
  )}
679
680
  </div>
680
- <ChatIdle.Provider value={idle}>
681
- <TimelineRows blocks={blocks} actions={actions} />
682
- </ChatIdle.Provider>
681
+ <ChatSessionId.Provider value={sessionId}>
682
+ <ChatIdle.Provider value={idle}>
683
+ <TimelineRows blocks={blocks} actions={actions} />
684
+ </ChatIdle.Provider>
685
+ </ChatSessionId.Provider>
683
686
  {pending.map((p) => (
684
687
  <div
685
688
  key={p.id}
@@ -4,6 +4,9 @@ import { useState } from "react";
4
4
  import { ChevronRight } from "lucide-react";
5
5
  import type { ChatItem, McpServerView } from "@/lib/chat/events";
6
6
  import { cn } from "@/lib/utils";
7
+ import { McpSignIn } from "./McpSignIn";
8
+ import { useChatSessionId } from "./session";
9
+ import { useMcpSignIn } from "@/data/chat/mcpAuth";
7
10
 
8
11
  type Status = McpServerView["status"];
9
12
 
@@ -25,38 +28,72 @@ function summary(servers: McpServerView[]): string {
25
28
  return parts.join(" · ");
26
29
  }
27
30
 
28
- function Server({ server }: { server: McpServerView }) {
31
+ function Server({ server, itemId }: { server: McpServerView; itemId: string }) {
32
+ const sessionId = useChatSessionId();
29
33
  const [open, setOpen] = useState(false);
34
+ const [signingIn, setSigningIn] = useState(false);
35
+ const flow = useMcpSignIn(sessionId ?? "", itemId, server.name);
36
+ const needsAuth = server.status === "needs-auth";
37
+ const cantSignIn = needsAuth && !server.signIn;
30
38
  const status = STATUS[server.status];
31
39
  const tools = server.tools.length;
40
+ const expandable = !!tools || !!server.error || cantSignIn;
32
41
  return (
33
42
  <li>
34
- <button
35
- type="button"
36
- onClick={() => setOpen(!open)}
37
- disabled={!tools && !server.error}
38
- aria-expanded={open}
39
- className="hover:bg-foreground/[0.04] flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left disabled:hover:bg-transparent md:min-h-9"
40
- >
41
- <span className={cn("h-2 w-2 shrink-0 rounded-full", status.dot)} />
42
- <span className="min-w-0 flex-1 truncate font-mono text-xs">
43
- {server.name}
44
- </span>
45
- <span className="text-muted-foreground shrink-0 text-xs">
46
- {status.label}
47
- {tools ? ` · ${tools} tool${tools === 1 ? "" : "s"}` : ""}
48
- </span>
49
- <ChevronRight
50
- className={cn(
51
- "text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform",
52
- open && "rotate-90",
53
- !tools && !server.error && "invisible"
54
- )}
43
+ <div className="flex items-center gap-1">
44
+ <button
45
+ type="button"
46
+ onClick={() => setOpen(!open)}
47
+ disabled={!expandable}
48
+ aria-expanded={open}
49
+ className="hover:bg-foreground/[0.04] flex min-h-11 min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 text-left disabled:hover:bg-transparent md:min-h-9"
50
+ >
51
+ <span className={cn("h-2 w-2 shrink-0 rounded-full", status.dot)} />
52
+ <span className="min-w-0 flex-1 truncate font-mono text-xs">
53
+ {server.name}
54
+ </span>
55
+ <span className="text-muted-foreground shrink-0 text-xs">
56
+ {status.label}
57
+ {tools ? ` · ${tools} tool${tools === 1 ? "" : "s"}` : ""}
58
+ </span>
59
+ <ChevronRight
60
+ className={cn(
61
+ "text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform",
62
+ open && "rotate-90",
63
+ !expandable && "invisible"
64
+ )}
65
+ />
66
+ </button>
67
+ {server.signIn && sessionId && !signingIn && (
68
+ <button
69
+ type="button"
70
+ onClick={() => {
71
+ setSigningIn(true);
72
+ flow.start.mutate();
73
+ }}
74
+ aria-label={`Sign in to ${server.name}`}
75
+ className="bg-secondary text-secondary-foreground hairline h-11 shrink-0 rounded-lg px-3 text-xs font-medium md:h-8"
76
+ >
77
+ Sign in
78
+ </button>
79
+ )}
80
+ </div>
81
+ {signingIn && sessionId && needsAuth && (
82
+ <McpSignIn
83
+ flow={flow}
84
+ server={server.name}
85
+ onClose={() => setSigningIn(false)}
55
86
  />
56
- </button>
87
+ )}
57
88
  {open && (
58
89
  <div className="text-muted-foreground space-y-1 pt-1 pr-2 pb-2 pl-6.5 text-xs">
59
90
  {server.scope && <p>From {server.scope} config</p>}
91
+ {cantSignIn && (
92
+ <p>
93
+ AgentOS can&apos;t sign in to this server. Sign in from a terminal
94
+ session with /mcp.
95
+ </p>
96
+ )}
60
97
  {server.error && (
61
98
  <p className="text-destructive whitespace-pre-wrap">
62
99
  {server.error}
@@ -98,7 +135,7 @@ export function McpServers({
98
135
  ) : (
99
136
  <ul>
100
137
  {item.servers.map((s) => (
101
- <Server key={s.name} server={s} />
138
+ <Server key={s.name} server={s} itemId={item.id} />
102
139
  ))}
103
140
  </ul>
104
141
  )}