@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.
- package/app/api/audits/[kind]/[id]/route.test.ts +2 -2
- package/app/api/mcp-auth/callback/route.test.ts +42 -0
- package/app/api/mcp-auth/callback/route.ts +56 -0
- package/app/api/projects/init/route.test.ts +2 -4
- package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
- package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
- package/app/api/shares/route.test.ts +3 -4
- package/app/api/workspaces/[id]/route.ts +9 -1
- package/app/api/worktree-sweep/route.test.ts +21 -0
- package/app/api/worktree-sweep/route.ts +24 -4
- package/components/Chat/ChatPanel.tsx +6 -3
- package/components/Chat/McpServers.tsx +61 -24
- package/components/Chat/McpSignIn.tsx +122 -0
- package/components/Chat/session.ts +8 -0
- package/components/Health/SweepRuns.tsx +1 -0
- package/components/Workspaces/WorkspaceSettings.tsx +6 -0
- package/data/chat/mcpAuth.ts +42 -0
- package/data/workspaces/index.ts +6 -0
- package/lib/chat/driver.ts +24 -2
- package/lib/chat/drivers/claude-approvals.ts +7 -0
- package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
- package/lib/chat/drivers/claude.ts +7 -0
- package/lib/chat/events.ts +12 -0
- package/lib/chat/mcp-auth.ts +193 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +18 -0
- package/lib/chat/worker/client.ts +35 -1
- package/lib/chat/worker/host.ts +9 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/mcp-auth.ts +119 -0
- package/lib/chat/worker/protocol.ts +19 -0
- package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
- package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/testing.ts +2 -2
- package/lib/env-setup.ts +8 -1
- package/lib/health/boots.ts +2 -8
- package/lib/orchestrator/armed.test-helpers.ts +3 -2
- package/lib/orchestrator/brief.ts +14 -9
- package/lib/orchestrator/external-gates.ts +62 -1
- package/lib/orchestrator/full-access.ts +21 -0
- package/lib/orchestrator/quick.ts +47 -4
- package/lib/orchestrator/serve.ts +11 -2
- package/lib/orchestrator/shell-guard.ts +23 -1
- package/lib/orchestrator/signoff.test-helpers.ts +3 -2
- package/lib/orchestrator/sweep-arm.ts +180 -0
- package/lib/orchestrator/tool-names.ts +4 -1
- package/lib/orchestrator/tool-schemas.ts +33 -1
- package/lib/orchestrator/tools.ts +6 -2
- package/lib/orchestrator/write-guard.ts +34 -7
- package/lib/project-config/brief.ts +3 -1
- package/lib/project-config/database.ts +6 -0
- package/lib/project-config/index.ts +108 -21
- package/lib/project-config/schema.ts +10 -4
- package/lib/project-config/session.ts +9 -6
- package/lib/security/net.ts +7 -4
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/test-temp.ts +30 -0
- package/lib/workspaces.ts +6 -2
- package/lib/worktree-sweep/caches.ts +6 -0
- package/lib/worktree-sweep/extras.ts +16 -8
- package/lib/worktree-sweep/report.ts +1 -0
- package/lib/worktree-sweep/settings.ts +2 -2
- package/lib/worktree-sweep/testTemp.ts +113 -0
- package/lib/worktree-sweep/types.ts +2 -0
- package/package.json +1 -1
- package/scripts/autodeploy.test.ts +2 -2
- package/scripts/check-bundle.test.ts +2 -2
- package/scripts/hooks/banned-terms.test.ts +3 -2
- package/scripts/hooks/staged-secrets.test.ts +2 -2
- package/scripts/lib/build-swap.test.ts +2 -3
- package/scripts/lib/wsl.test.ts +2 -3
- 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 =
|
|
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("<b>linear</b>");
|
|
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("<script>");
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
<
|
|
681
|
-
<
|
|
682
|
-
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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'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
|
)}
|