@saadnvd1/agent-os 0.3.662 → 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/sessions/[id]/route.ts +3 -3
- 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 +40 -1
- package/app/api/worktree-sweep/route.ts +45 -6
- 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 +77 -1
- package/components/Workspaces/WorkspaceSettings.tsx +6 -0
- package/data/chat/mcpAuth.ts +42 -0
- package/data/workspaces/index.ts +6 -0
- package/data/worktree-sweep/index.ts +15 -0
- package/lib/agentos-home.ts +11 -0
- package/lib/agents/launch.ts +3 -7
- package/lib/artifacts/store.ts +2 -5
- 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/drivers/pi-approvals.ts +2 -2
- 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 +21 -2
- package/lib/connect/config.ts +2 -3
- package/lib/db/backup.ts +2 -2
- package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -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/dev-instance/processInfo.ts +48 -27
- package/lib/dev-instance/startDevServer.ts +1 -1
- package/lib/dev-instance/stopDevServer.ts +20 -17
- package/lib/dev-servers.ts +2 -1
- package/lib/done/index.ts +2 -2
- package/lib/done/testing.ts +2 -2
- package/lib/env-setup.ts +8 -1
- package/lib/health/boots.ts +31 -0
- package/lib/health/ci.ts +23 -2
- package/lib/health/detectors.ts +2 -1
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +3 -3
- package/lib/health/fixtures.ts +1 -0
- package/lib/health/tick.ts +6 -1
- package/lib/health/types.ts +2 -0
- 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/home.ts +2 -5
- package/lib/orchestrator/quick.ts +47 -4
- package/lib/orchestrator/serve.ts +11 -2
- package/lib/orchestrator/shell-guard.ts +24 -0
- 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 +37 -9
- package/lib/plans/features.ts +2 -2
- package/lib/program-status/claude-hooks.ts +2 -2
- package/lib/program-status/tap.ts +2 -2
- package/lib/project-config/brief.ts +3 -1
- package/lib/project-config/database.ts +77 -16
- package/lib/project-config/index.ts +108 -21
- package/lib/project-config/schema.ts +10 -4
- package/lib/project-config/session.ts +27 -6
- package/lib/security/net.ts +7 -4
- package/lib/security/tailnet-https.ts +2 -2
- package/lib/session-artifacts/collect.ts +18 -1
- package/lib/session-artifacts/keep.ts +2 -4
- package/lib/session-audit/store.ts +2 -5
- package/lib/session-logs/folder.ts +2 -3
- package/lib/sessions/launch.ts +2 -2
- package/lib/sessions/leftovers.ts +61 -0
- package/lib/simulator/agent-device.ts +2 -2
- package/lib/tasks/finish.ts +2 -2
- package/lib/tasks/import.ts +3 -5
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/move.ts +2 -2
- package/lib/tasks/moved-away.ts +2 -0
- package/lib/tasks/state.ts +7 -3
- package/lib/tasks/ui-brief.ts +2 -2
- package/lib/test-temp.ts +30 -0
- package/lib/workspaces.ts +6 -2
- package/lib/worktree-deps/fastcopy.ts +2 -2
- package/lib/worktree-deps/spare.ts +2 -2
- package/lib/worktree-sweep/caches.ts +129 -0
- package/lib/worktree-sweep/databases.ts +222 -0
- package/lib/worktree-sweep/du.ts +13 -0
- package/lib/worktree-sweep/extras.ts +196 -0
- package/lib/worktree-sweep/findOrphans.ts +19 -8
- package/lib/worktree-sweep/report.ts +86 -0
- package/lib/worktree-sweep/runSweep.ts +46 -13
- package/lib/worktree-sweep/settings.ts +24 -0
- package/lib/worktree-sweep/state.ts +5 -0
- package/lib/worktree-sweep/store.ts +11 -0
- package/lib/worktree-sweep/testTemp.ts +113 -0
- package/lib/worktree-sweep/types.ts +46 -1
- package/lib/worktrees.ts +3 -0
- package/package.json +2 -1
- package/scripts/autodeploy.test.ts +2 -2
- package/scripts/check-bundle.test.ts +2 -2
- package/scripts/disk-dry-run.ts +57 -0
- 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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in to an MCP server from the browser. The agent starts the
|
|
3
|
+
* sign-in and gives a link; the provider then sends the browser back to
|
|
4
|
+
* AgentOS (/api/mcp-auth/callback), which hands that address to the agent.
|
|
5
|
+
* What ties the return to the attempt is the OAuth `state`, kept in SQLite
|
|
6
|
+
* (the start and callback routes are separate bundles, so memory isn't
|
|
7
|
+
* shared), used once, and dropped after PENDING_MS. The agent holds the
|
|
8
|
+
* tokens; nothing here stores or logs the address it was given.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { db } from "../db";
|
|
12
|
+
import { getItem } from "./store";
|
|
13
|
+
import { mcpAuthWorker } from "./runner";
|
|
14
|
+
import type { McpSignInStart } from "./events";
|
|
15
|
+
|
|
16
|
+
export const PENDING_MS = 15 * 60 * 1000;
|
|
17
|
+
export const CALLBACK_PATH = "/api/mcp-auth/callback";
|
|
18
|
+
|
|
19
|
+
interface Pending {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
itemId: string;
|
|
22
|
+
server: string;
|
|
23
|
+
origin: string | null;
|
|
24
|
+
at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PendingRow {
|
|
28
|
+
session_id: string;
|
|
29
|
+
item_id: string;
|
|
30
|
+
server: string;
|
|
31
|
+
origin: string | null;
|
|
32
|
+
at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetMcpSignIns(): void {
|
|
36
|
+
db.prepare(`DELETE FROM mcp_sign_ins`).run();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sweep(now = Date.now()): void {
|
|
40
|
+
db.prepare(`DELETE FROM mcp_sign_ins WHERE at < ?`).run(now - PENDING_MS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function savePending(state: string, p: Pending): void {
|
|
44
|
+
db.prepare(
|
|
45
|
+
`DELETE FROM mcp_sign_ins WHERE session_id = ? AND server = ?`
|
|
46
|
+
).run(p.sessionId, p.server);
|
|
47
|
+
db.prepare(
|
|
48
|
+
`INSERT OR REPLACE INTO mcp_sign_ins (state, session_id, item_id, server, origin, at)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
50
|
+
).run(state, p.sessionId, p.itemId, p.server, p.origin, p.at);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function peekPending(state: string): Pending | undefined {
|
|
54
|
+
const r = db
|
|
55
|
+
.prepare(`SELECT * FROM mcp_sign_ins WHERE state = ?`)
|
|
56
|
+
.get(state) as PendingRow | undefined;
|
|
57
|
+
return (
|
|
58
|
+
r && {
|
|
59
|
+
sessionId: r.session_id,
|
|
60
|
+
itemId: r.item_id,
|
|
61
|
+
server: r.server,
|
|
62
|
+
origin: r.origin,
|
|
63
|
+
at: r.at,
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Single use: only the caller whose delete changed a row may go on.
|
|
69
|
+
function takePending(state: string): boolean {
|
|
70
|
+
return (
|
|
71
|
+
db.prepare(`DELETE FROM mcp_sign_ins WHERE state = ?`).run(state).changes >
|
|
72
|
+
0
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `http(s)://host[:port]`, nothing else: the browser's own address for
|
|
77
|
+
// AgentOS, so the provider returns to wherever the user is.
|
|
78
|
+
export function cleanOrigin(value: string | null | undefined): string | null {
|
|
79
|
+
if (!value) return null;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(value);
|
|
82
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
83
|
+
return url.origin === value ? url.origin : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function serverOf(sessionId: string, itemId: string, server: string) {
|
|
90
|
+
const item = getItem(sessionId, itemId);
|
|
91
|
+
if (item?.kind !== "mcp") throw new Error("That list is gone");
|
|
92
|
+
const view = item.servers.find((s) => s.name === server);
|
|
93
|
+
if (!view) throw new Error(`${server} isn't in that list`);
|
|
94
|
+
return view;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function startMcpSignIn(
|
|
98
|
+
sessionId: string,
|
|
99
|
+
input: { itemId: string; server: string; origin?: string | null }
|
|
100
|
+
): Promise<McpSignInStart> {
|
|
101
|
+
if (!serverOf(sessionId, input.itemId, input.server).signIn)
|
|
102
|
+
throw new Error("AgentOS can't sign in to this server");
|
|
103
|
+
const worker = mcpAuthWorker(sessionId);
|
|
104
|
+
const origin = cleanOrigin(input.origin);
|
|
105
|
+
const { start, error } = await worker.mcpAuth(
|
|
106
|
+
{
|
|
107
|
+
action: "start",
|
|
108
|
+
server: input.server,
|
|
109
|
+
itemId: input.itemId,
|
|
110
|
+
redirectUri: origin ? `${origin}${CALLBACK_PATH}` : undefined,
|
|
111
|
+
},
|
|
112
|
+
30_000
|
|
113
|
+
);
|
|
114
|
+
if (error || !start) throw new Error(error ?? "Sign-in didn't start");
|
|
115
|
+
sweep();
|
|
116
|
+
const { state, ...result } = start;
|
|
117
|
+
if (state)
|
|
118
|
+
savePending(state, {
|
|
119
|
+
sessionId,
|
|
120
|
+
itemId: input.itemId,
|
|
121
|
+
server: input.server,
|
|
122
|
+
origin,
|
|
123
|
+
at: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The provider returned the browser (or the user pasted the address it
|
|
129
|
+
// ended on). `expect` pins a pasted address to the row it was typed in.
|
|
130
|
+
export async function finishMcpSignIn(
|
|
131
|
+
callbackUrl: string,
|
|
132
|
+
expect?: { sessionId: string; server: string }
|
|
133
|
+
): Promise<{ server: string }> {
|
|
134
|
+
sweep();
|
|
135
|
+
let state: string | null = null;
|
|
136
|
+
try {
|
|
137
|
+
state = new URL(callbackUrl).searchParams.get("state");
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error("That isn't a web address");
|
|
140
|
+
}
|
|
141
|
+
const p = state ? peekPending(state) : undefined;
|
|
142
|
+
if (!state || !p)
|
|
143
|
+
throw new Error(
|
|
144
|
+
"That sign-in expired or was already used. Press Sign in again."
|
|
145
|
+
);
|
|
146
|
+
if (
|
|
147
|
+
expect &&
|
|
148
|
+
(expect.sessionId !== p.sessionId || expect.server !== p.server)
|
|
149
|
+
)
|
|
150
|
+
throw new Error("That address belongs to a different sign-in");
|
|
151
|
+
if (!takePending(state))
|
|
152
|
+
throw new Error(
|
|
153
|
+
"That sign-in expired or was already used. Press Sign in again."
|
|
154
|
+
);
|
|
155
|
+
const { error } = await mcpAuthWorker(p.sessionId).mcpAuth({
|
|
156
|
+
action: "submit",
|
|
157
|
+
server: p.server,
|
|
158
|
+
itemId: p.itemId,
|
|
159
|
+
url: callbackUrl,
|
|
160
|
+
});
|
|
161
|
+
if (error) throw new Error(error);
|
|
162
|
+
return { server: p.server };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The provider's redirect reached our callback route. Rebuild the address
|
|
166
|
+
// from the origin the sign-in started with, not from the request (behind the
|
|
167
|
+
// custom server Next may derive it from Host), so it matches the
|
|
168
|
+
// redirect_uri the provider was given exactly.
|
|
169
|
+
export async function finishMcpSignInFromRedirect(
|
|
170
|
+
search: string
|
|
171
|
+
): Promise<{ server: string }> {
|
|
172
|
+
sweep();
|
|
173
|
+
const state = new URLSearchParams(search).get("state");
|
|
174
|
+
const origin = state ? peekPending(state)?.origin : null;
|
|
175
|
+
if (!origin)
|
|
176
|
+
throw new Error(
|
|
177
|
+
"That sign-in expired or was already used. Press Sign in again."
|
|
178
|
+
);
|
|
179
|
+
return finishMcpSignIn(`${origin}${CALLBACK_PATH}${search}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// "Check again": reconnect and refresh the list.
|
|
183
|
+
export async function reconnectMcpServer(
|
|
184
|
+
sessionId: string,
|
|
185
|
+
input: { itemId: string; server: string }
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
serverOf(sessionId, input.itemId, input.server);
|
|
188
|
+
const { error } = await mcpAuthWorker(sessionId).mcpAuth(
|
|
189
|
+
{ action: "reconnect", ...input },
|
|
190
|
+
30_000
|
|
191
|
+
);
|
|
192
|
+
if (error) throw new Error(error);
|
|
193
|
+
}
|
package/lib/chat/registry.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface Live {
|
|
|
38
38
|
canFresh?: boolean;
|
|
39
39
|
// Whether it takes "background_task" (an older worker drops it).
|
|
40
40
|
canBackground?: boolean;
|
|
41
|
+
// Whether it takes "mcp_auth" (sign in to an MCP server).
|
|
42
|
+
canMcpAuth?: boolean;
|
|
41
43
|
// From an older build and idle: retired once its guess at the next
|
|
42
44
|
// message is in, or this fires.
|
|
43
45
|
retiring?: NodeJS.Timeout;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
} from "./worker/client";
|
|
88
88
|
import { buildId, isStaleWorker } from "../build";
|
|
89
89
|
import type { WorkerEvent } from "./worker/protocol";
|
|
90
|
+
import type { WorkerClient } from "./worker/client";
|
|
90
91
|
import { DEMO_REFUSAL, demoMode } from "../security/demo";
|
|
91
92
|
import { chatHold, chatRefusal } from "./hold";
|
|
92
93
|
import { getHost } from "../hosts";
|
|
@@ -363,6 +364,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
363
364
|
canQueue: !!hello.caps?.includes("queue"),
|
|
364
365
|
canFresh: !!hello.caps?.includes("fresh"),
|
|
365
366
|
canBackground: !!hello.caps?.includes("background"),
|
|
367
|
+
canMcpAuth: !!hello.caps?.includes("mcp_auth"),
|
|
366
368
|
};
|
|
367
369
|
// From an older build and busy, or running background work: it goes at
|
|
368
370
|
// its next turn boundary, by itself if it can. Messages meanwhile wait
|
|
@@ -932,6 +934,22 @@ export async function backgroundChatTask(
|
|
|
932
934
|
});
|
|
933
935
|
}
|
|
934
936
|
|
|
937
|
+
// The session's running worker, when it can sign in to MCP servers; why not
|
|
938
|
+
// otherwise (a chat on another machine, an idle one, one from an older build).
|
|
939
|
+
export function mcpAuthWorker(sessionId: string): WorkerClient {
|
|
940
|
+
checkChattable(getSession(sessionId));
|
|
941
|
+
const live = registry.live.get(sessionId);
|
|
942
|
+
if (!live || live.retiring)
|
|
943
|
+
throw new Error(
|
|
944
|
+
"This chat's agent isn't running. Send /mcp again to wake it, then sign in."
|
|
945
|
+
);
|
|
946
|
+
if (!live.canMcpAuth)
|
|
947
|
+
throw new Error(
|
|
948
|
+
"This chat's agent is from an older AgentOS build. Send it a message or reopen the chat, then try again."
|
|
949
|
+
);
|
|
950
|
+
return live.worker;
|
|
951
|
+
}
|
|
952
|
+
|
|
935
953
|
export function chatTaskOutput(
|
|
936
954
|
sessionId: string,
|
|
937
955
|
taskId: string
|
|
@@ -12,7 +12,8 @@ import path from "path";
|
|
|
12
12
|
import { promisify } from "util";
|
|
13
13
|
import { endTmuxSession } from "../../tmux/end";
|
|
14
14
|
import type { UndoResult } from "../driver";
|
|
15
|
-
import type { FileSuggestion } from "../events";
|
|
15
|
+
import type { FileSuggestion, McpSignInStart } from "../events";
|
|
16
|
+
import type { McpAuthCommand } from "./mcp-auth";
|
|
16
17
|
import {
|
|
17
18
|
envPath,
|
|
18
19
|
lineReader,
|
|
@@ -47,6 +48,11 @@ export class WorkerClient {
|
|
|
47
48
|
(r: { ok?: boolean; error?: string }) => void
|
|
48
49
|
>();
|
|
49
50
|
|
|
51
|
+
private mcpAuths = new Map<
|
|
52
|
+
string,
|
|
53
|
+
(r: { start?: McpSignInStart & { state?: string }; error?: string }) => void
|
|
54
|
+
>();
|
|
55
|
+
|
|
50
56
|
private constructor(
|
|
51
57
|
private socket: net.Socket,
|
|
52
58
|
private handlers: WorkerHandlers
|
|
@@ -115,6 +121,11 @@ export class WorkerClient {
|
|
|
115
121
|
this.backgrounds.delete(e.reqId);
|
|
116
122
|
return;
|
|
117
123
|
}
|
|
124
|
+
if (e.type === "mcp_auth_result") {
|
|
125
|
+
this.mcpAuths.get(e.reqId)?.({ start: e.start, error: e.error });
|
|
126
|
+
this.mcpAuths.delete(e.reqId);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
118
129
|
if (e.type === "files_result") {
|
|
119
130
|
this.files.get(e.reqId)?.(e.files ?? null);
|
|
120
131
|
this.files.delete(e.reqId);
|
|
@@ -155,6 +166,29 @@ export class WorkerClient {
|
|
|
155
166
|
});
|
|
156
167
|
}
|
|
157
168
|
|
|
169
|
+
// Signs in to an MCP server (start, finish or reconnect): what it came back
|
|
170
|
+
// with, or why not (an error, or no answer in time).
|
|
171
|
+
mcpAuth(
|
|
172
|
+
cmd: Omit<McpAuthCommand, "type" | "reqId">,
|
|
173
|
+
timeoutMs = 60_000
|
|
174
|
+
): Promise<{
|
|
175
|
+
start?: McpSignInStart & { state?: string };
|
|
176
|
+
error?: string;
|
|
177
|
+
}> {
|
|
178
|
+
const reqId = randomUUID();
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
this.mcpAuths.delete(reqId);
|
|
182
|
+
resolve({ error: "The agent didn't answer in time" });
|
|
183
|
+
}, timeoutMs);
|
|
184
|
+
this.mcpAuths.set(reqId, (r) => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
resolve(r);
|
|
187
|
+
});
|
|
188
|
+
this.command({ type: "mcp_auth", reqId, ...cmd });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
// The agent's own matches for an @mention; null when it can't say (an
|
|
159
193
|
// older worker, an error, no answer in time).
|
|
160
194
|
fileSuggestions(
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -25,10 +25,12 @@ import {
|
|
|
25
25
|
itemsOfKind,
|
|
26
26
|
flushItems,
|
|
27
27
|
moveToEnd,
|
|
28
|
+
getItem,
|
|
28
29
|
queueItem,
|
|
29
30
|
runningTools,
|
|
30
31
|
settle,
|
|
31
32
|
} from "../store";
|
|
33
|
+
import { handleMcpAuth, stopMcpWatches } from "./mcp-auth";
|
|
32
34
|
import { claimNext, enqueue, listQueue, moveToFront } from "../queued";
|
|
33
35
|
import { isHeld } from "../hold";
|
|
34
36
|
import {
|
|
@@ -804,6 +806,12 @@ export class ChatHost {
|
|
|
804
806
|
});
|
|
805
807
|
}
|
|
806
808
|
return;
|
|
809
|
+
case "mcp_auth":
|
|
810
|
+
return handleMcpAuth(cmd, this.conversation, {
|
|
811
|
+
emit: (e) => this.emit(e),
|
|
812
|
+
record: (item) => this.record(item),
|
|
813
|
+
item: (id) => getItem(this.session.id, id),
|
|
814
|
+
});
|
|
807
815
|
case "undo":
|
|
808
816
|
try {
|
|
809
817
|
const result = await this.conversation.undo(
|
|
@@ -1051,6 +1059,7 @@ export class ChatHost {
|
|
|
1051
1059
|
this.setCompacting(false);
|
|
1052
1060
|
flushItems();
|
|
1053
1061
|
this.onClose();
|
|
1062
|
+
stopMcpWatches(this.conversation.mcpAuth);
|
|
1054
1063
|
this.conversation.close();
|
|
1055
1064
|
}
|
|
1056
1065
|
}
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -76,7 +76,7 @@ async function main(sessionId: string) {
|
|
|
76
76
|
state: host.state,
|
|
77
77
|
compacting: host.compactingNow,
|
|
78
78
|
streaming: [...host.streaming.values()],
|
|
79
|
-
caps: ["plan", "queue", "retire", "fresh", "background"],
|
|
79
|
+
caps: ["plan", "queue", "retire", "fresh", "background", "mcp_auth"],
|
|
80
80
|
} satisfies WorkerEvent)}\n`
|
|
81
81
|
);
|
|
82
82
|
client.on(
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in to an MCP server from a chat: the worker owns the agent, so it
|
|
3
|
+
* starts the sign-in, hands over the address the provider returned to, and
|
|
4
|
+
* reconnects. The /mcp item it came from is rewritten in place (same id) so
|
|
5
|
+
* its row turns connected. A sign-in the browser can't finish by itself (it
|
|
6
|
+
* happens on another machine, or on claude.ai) is watched until the server
|
|
7
|
+
* stops needing it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChatConversation, McpAuth } from "../driver";
|
|
11
|
+
import type { ChatItem, McpSignInStart } from "../events";
|
|
12
|
+
import { cleanMcpError } from "../drivers/claude";
|
|
13
|
+
import type { WorkerCommand, WorkerEvent } from "./protocol";
|
|
14
|
+
|
|
15
|
+
export type McpAuthCommand = Extract<WorkerCommand, { type: "mcp_auth" }>;
|
|
16
|
+
|
|
17
|
+
export const WATCH_EVERY_MS = 2000;
|
|
18
|
+
export const WATCH_FOR_MS = 10 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
interface Host {
|
|
21
|
+
emit(e: WorkerEvent): void;
|
|
22
|
+
record(item: ChatItem): void;
|
|
23
|
+
item(id: string): ChatItem | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const watches = new WeakMap<object, Map<string, NodeJS.Timeout>>();
|
|
27
|
+
|
|
28
|
+
function stopWatch(owner: object, server: string): void {
|
|
29
|
+
const timer = watches.get(owner)?.get(server);
|
|
30
|
+
if (timer) clearInterval(timer);
|
|
31
|
+
watches.get(owner)?.delete(server);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The conversation is closing: no watch outlives it.
|
|
35
|
+
export function stopMcpWatches(auth: object | undefined): void {
|
|
36
|
+
if (!auth) return;
|
|
37
|
+
for (const timer of watches.get(auth)?.values() ?? []) clearInterval(timer);
|
|
38
|
+
watches.delete(auth);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Rewrites the /mcp item with the servers as they are now.
|
|
42
|
+
async function refresh(auth: McpAuth, host: Host, itemId: string) {
|
|
43
|
+
const item = host.item(itemId);
|
|
44
|
+
if (item?.kind !== "mcp") return;
|
|
45
|
+
host.record({ ...item, servers: await auth.servers() });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function watch(auth: McpAuth, host: Host, itemId: string, server: string) {
|
|
49
|
+
const owner = auth;
|
|
50
|
+
stopWatch(owner, server);
|
|
51
|
+
const timers = watches.get(owner) ?? new Map();
|
|
52
|
+
watches.set(owner, timers);
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
let busy = false;
|
|
55
|
+
const timer = setInterval(async () => {
|
|
56
|
+
if (busy) return;
|
|
57
|
+
busy = true;
|
|
58
|
+
try {
|
|
59
|
+
const now = (await auth.servers()).find((s) => s.name === server);
|
|
60
|
+
if (now && now.status !== "needs-auth") {
|
|
61
|
+
stopWatch(owner, server);
|
|
62
|
+
await refresh(auth, host, itemId);
|
|
63
|
+
} else if (Date.now() - started > WATCH_FOR_MS) stopWatch(owner, server);
|
|
64
|
+
} catch {
|
|
65
|
+
stopWatch(owner, server);
|
|
66
|
+
} finally {
|
|
67
|
+
busy = false;
|
|
68
|
+
}
|
|
69
|
+
}, WATCH_EVERY_MS);
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
timers.set(server, timer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function handleMcpAuth(
|
|
75
|
+
cmd: McpAuthCommand,
|
|
76
|
+
conversation: Pick<ChatConversation, "mcpAuth">,
|
|
77
|
+
host: Host
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const auth = conversation.mcpAuth;
|
|
80
|
+
const reply = (r: { start?: McpSignInStart; error?: string }) =>
|
|
81
|
+
host.emit({ type: "mcp_auth_result", reqId: cmd.reqId, ...r });
|
|
82
|
+
if (!auth) return reply({ error: "This agent can't sign in to MCP servers" });
|
|
83
|
+
try {
|
|
84
|
+
if (cmd.action === "start") {
|
|
85
|
+
const { state, ...start } = await auth.start(cmd.server, cmd.redirectUri);
|
|
86
|
+
if (start.mode === "done") {
|
|
87
|
+
await auth.reconnect(cmd.server);
|
|
88
|
+
await refresh(auth, host, cmd.itemId);
|
|
89
|
+
} else if (start.mode !== "callback")
|
|
90
|
+
watch(auth, host, cmd.itemId, cmd.server);
|
|
91
|
+
// The state ties the provider's return to this attempt; the server
|
|
92
|
+
// keeps it, the browser never needs it.
|
|
93
|
+
return reply({ start: { ...start, ...(state && { state }) } });
|
|
94
|
+
}
|
|
95
|
+
stopWatch(auth, cmd.server);
|
|
96
|
+
let failure: unknown;
|
|
97
|
+
try {
|
|
98
|
+
if (cmd.action === "submit") {
|
|
99
|
+
if (!cmd.url) throw new Error("No address to finish with");
|
|
100
|
+
await auth.submit(cmd.server, cmd.url);
|
|
101
|
+
}
|
|
102
|
+
await auth.reconnect(cmd.server);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
failure = error;
|
|
105
|
+
}
|
|
106
|
+
await refresh(auth, host, cmd.itemId).catch(() => {});
|
|
107
|
+
reply(failure ? { error: message(failure) } : {});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
reply({ error: message(error) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// An error can quote the server's URL, and a key in it.
|
|
114
|
+
function message(error: unknown): string {
|
|
115
|
+
return (
|
|
116
|
+
cleanMcpError(error instanceof Error ? error.message : String(error)) ??
|
|
117
|
+
"Sign-in failed"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* worker's Unix socket: one JSON message per line.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { agentosHome } from "../../agentos-home";
|
|
6
7
|
import crypto from "crypto";
|
|
7
|
-
import os from "os";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import type {
|
|
10
10
|
ApprovalDecision,
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
Compacting,
|
|
16
16
|
DriverEvent,
|
|
17
17
|
FileSuggestion,
|
|
18
|
+
McpSignInStart,
|
|
18
19
|
ChatOrigin,
|
|
19
20
|
PeerMessage,
|
|
20
21
|
} from "../events";
|
|
@@ -56,6 +57,18 @@ export type WorkerCommand =
|
|
|
56
57
|
| { type: "undo"; reqId: string; checkpoint: string; dryRun: boolean }
|
|
57
58
|
| { type: "stop_task"; taskId: string }
|
|
58
59
|
| { type: "background_task"; reqId: string; toolUseId?: string }
|
|
60
|
+
// Sign in to an MCP server listed in the /mcp item `itemId`: start it
|
|
61
|
+
// (redirectUri: where the provider should return the browser), finish it
|
|
62
|
+
// with the address it returned to (url), or just reconnect.
|
|
63
|
+
| {
|
|
64
|
+
type: "mcp_auth";
|
|
65
|
+
reqId: string;
|
|
66
|
+
action: "start" | "submit" | "reconnect";
|
|
67
|
+
server: string;
|
|
68
|
+
itemId: string;
|
|
69
|
+
redirectUri?: string;
|
|
70
|
+
url?: string;
|
|
71
|
+
}
|
|
59
72
|
// It runs an older build: it takes no new message into its agent (they
|
|
60
73
|
// wait in the queue) and closes at its next turn boundary, so a current
|
|
61
74
|
// worker resumes the conversation with today's tools.
|
|
@@ -101,6 +114,12 @@ export type WorkerEvent =
|
|
|
101
114
|
| { type: "suggestion"; text: string | null }
|
|
102
115
|
| { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }
|
|
103
116
|
| { type: "background_result"; reqId: string; ok?: boolean; error?: string }
|
|
117
|
+
| {
|
|
118
|
+
type: "mcp_auth_result";
|
|
119
|
+
reqId: string;
|
|
120
|
+
start?: McpSignInStart & { state?: string };
|
|
121
|
+
error?: string;
|
|
122
|
+
}
|
|
104
123
|
| {
|
|
105
124
|
type: "files_result";
|
|
106
125
|
reqId: string;
|
|
@@ -116,7 +135,7 @@ export function workerDir(): string {
|
|
|
116
135
|
process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
|
|
117
136
|
);
|
|
118
137
|
const key = crypto.createHash("sha1").update(db).digest("hex").slice(0, 8);
|
|
119
|
-
return
|
|
138
|
+
return agentosHome("chat", key);
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
export const socketPath = (sessionId: string) =>
|
package/lib/connect/config.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* each time the tunnel starts.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { agentosHome } from "../agentos-home";
|
|
14
15
|
import fs from "fs";
|
|
15
|
-
import os from "os";
|
|
16
16
|
import path from "path";
|
|
17
17
|
|
|
18
18
|
export interface ConnectConfig {
|
|
@@ -32,8 +32,7 @@ export interface ConnectFiles {
|
|
|
32
32
|
export const MACHINE_HOST = /^m-[0-9a-f]{16}\.runagentos\.com$/;
|
|
33
33
|
|
|
34
34
|
export const connectDir = () =>
|
|
35
|
-
process.env.AGENTOS_CONNECT_DIR ||
|
|
36
|
-
path.join(os.homedir(), ".agent-os", "connect");
|
|
35
|
+
process.env.AGENTOS_CONNECT_DIR || agentosHome("connect");
|
|
37
36
|
|
|
38
37
|
export type ConnectRead =
|
|
39
38
|
| { ok: true; files: ConnectFiles; enabled: boolean }
|
package/lib/db/backup.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
import { agentosHome } from "../agentos-home";
|
|
1
2
|
import type Database from "better-sqlite3";
|
|
2
3
|
import Sqlite from "better-sqlite3";
|
|
3
4
|
import { randomBytes } from "crypto";
|
|
4
5
|
import fs from "fs";
|
|
5
|
-
import os from "os";
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
8
8
|
|
|
9
9
|
export const backupsDir = (): string =>
|
|
10
|
-
refuseRealStateUnderTest(
|
|
10
|
+
refuseRealStateUnderTest(agentosHome("backups"));
|
|
11
11
|
|
|
12
12
|
// A full copy of the database (VACUUM INTO) under ~/.agent-os/backups, taken
|
|
13
13
|
// before a destructive migration. Throws, and the migration must stop, unless
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261018T1600_health_boot_deploys",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
const cols = (
|
|
7
|
+
db.prepare(`PRAGMA table_info(health_boots)`).all() as { name: string }[]
|
|
8
|
+
).map((c) => c.name);
|
|
9
|
+
// 1 when autodeploy planned the restart that started this boot.
|
|
10
|
+
if (!cols.includes("deploy"))
|
|
11
|
+
db.exec(`ALTER TABLE health_boots ADD COLUMN deploy INTEGER`);
|
|
12
|
+
},
|
|
13
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261018T1730_mcp_sign_ins",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// MCP sign-ins waiting for the provider to send the browser back, keyed by
|
|
7
|
+
// the OAuth state. Short-lived and single use; no tokens are stored.
|
|
8
|
+
// Additive.
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS mcp_sign_ins (
|
|
11
|
+
state TEXT PRIMARY KEY,
|
|
12
|
+
session_id TEXT NOT NULL,
|
|
13
|
+
item_id TEXT NOT NULL,
|
|
14
|
+
server TEXT NOT NULL,
|
|
15
|
+
origin TEXT,
|
|
16
|
+
at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_mcp_sign_ins_session
|
|
19
|
+
ON mcp_sign_ins(session_id, server);
|
|
20
|
+
`);
|
|
21
|
+
},
|
|
22
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261018T2100_orch_full_access",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Whether the workspace's orchestrator runs with its shell and file
|
|
7
|
+
// guards off (orchestrator/full-access.ts). On by default. Additive.
|
|
8
|
+
const cols = db.prepare(`PRAGMA table_info(workspaces)`).all() as {
|
|
9
|
+
name: string;
|
|
10
|
+
}[];
|
|
11
|
+
if (!cols.some((c) => c.name === "orch_full_access"))
|
|
12
|
+
db.exec(
|
|
13
|
+
`ALTER TABLE workspaces ADD COLUMN orch_full_access INTEGER NOT NULL DEFAULT 1`
|
|
14
|
+
);
|
|
15
|
+
},
|
|
16
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -296,6 +296,9 @@ export interface Workspace {
|
|
|
296
296
|
// Tasks are messaged about failing checks and reviews on their PRs
|
|
297
297
|
// (orchestrator/pr-wake.ts).
|
|
298
298
|
wake_tasks_on_pr: boolean;
|
|
299
|
+
// Its orchestrator's shell and file writes aren't checked, and it may merge
|
|
300
|
+
// directly (orchestrator/full-access.ts).
|
|
301
|
+
orch_full_access: boolean;
|
|
299
302
|
created_at: string;
|
|
300
303
|
}
|
|
301
304
|
|