@saadnvd1/agent-os 0.3.664 → 0.3.672

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 (95) 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/[id]/route.ts +26 -0
  5. package/app/api/projects/init/route.test.ts +2 -4
  6. package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
  7. package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
  8. package/app/api/shares/route.test.ts +3 -4
  9. package/app/api/workspaces/[id]/route.ts +9 -1
  10. package/app/api/worktree-sweep/route.test.ts +21 -0
  11. package/app/api/worktree-sweep/route.ts +24 -4
  12. package/components/Chat/ChatPanel.tsx +6 -3
  13. package/components/Chat/McpServers.tsx +61 -24
  14. package/components/Chat/McpSignIn.tsx +122 -0
  15. package/components/Chat/session.ts +8 -0
  16. package/components/Health/SweepRuns.tsx +1 -0
  17. package/components/Projects/ProjectSettingsDialog.tsx +7 -0
  18. package/components/Projects/ProjectTierField.tsx +39 -0
  19. package/components/Workspaces/ProjectTiersForm.tsx +33 -0
  20. package/components/Workspaces/WorkspaceSettings.tsx +8 -0
  21. package/data/chat/mcpAuth.ts +42 -0
  22. package/data/projects/queries.ts +4 -0
  23. package/data/workspaces/index.ts +6 -0
  24. package/lib/chat/driver.ts +24 -2
  25. package/lib/chat/drivers/claude-approvals.ts +7 -0
  26. package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
  27. package/lib/chat/drivers/claude.ts +7 -0
  28. package/lib/chat/events.ts +12 -0
  29. package/lib/chat/mcp-auth.ts +193 -0
  30. package/lib/chat/registry.ts +2 -0
  31. package/lib/chat/runner.ts +18 -0
  32. package/lib/chat/worker/client.ts +35 -1
  33. package/lib/chat/worker/host.ts +9 -0
  34. package/lib/chat/worker/main.ts +1 -1
  35. package/lib/chat/worker/mcp-auth.ts +119 -0
  36. package/lib/chat/worker/protocol.ts +19 -0
  37. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  38. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  39. package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
  40. package/lib/db/types.ts +7 -0
  41. package/lib/done/testing.ts +2 -2
  42. package/lib/env-setup.ts +8 -1
  43. package/lib/health/boots.ts +2 -8
  44. package/lib/orchestrator/armed.test-helpers.ts +7 -2
  45. package/lib/orchestrator/armed.ts +14 -1
  46. package/lib/orchestrator/brief.ts +23 -11
  47. package/lib/orchestrator/external-gates.ts +62 -1
  48. package/lib/orchestrator/fast-lane-state.ts +69 -0
  49. package/lib/orchestrator/fast-lane.ts +96 -0
  50. package/lib/orchestrator/full-access.ts +21 -0
  51. package/lib/orchestrator/gates.ts +8 -2
  52. package/lib/orchestrator/project-tier-tool.ts +33 -0
  53. package/lib/orchestrator/quick.ts +47 -4
  54. package/lib/orchestrator/review.ts +3 -0
  55. package/lib/orchestrator/serve.ts +17 -3
  56. package/lib/orchestrator/shell-guard.ts +23 -1
  57. package/lib/orchestrator/signoff.test-helpers.ts +6 -2
  58. package/lib/orchestrator/signoff.ts +36 -19
  59. package/lib/orchestrator/sweep-arm.ts +180 -0
  60. package/lib/orchestrator/tool-names.ts +5 -1
  61. package/lib/orchestrator/tool-schemas.ts +41 -2
  62. package/lib/orchestrator/tools.ts +8 -2
  63. package/lib/orchestrator/watcher.ts +5 -0
  64. package/lib/orchestrator/write-guard.ts +34 -7
  65. package/lib/project-config/brief.ts +3 -1
  66. package/lib/project-config/database.ts +6 -0
  67. package/lib/project-config/index.ts +108 -21
  68. package/lib/project-config/schema.ts +10 -4
  69. package/lib/project-config/session.ts +9 -6
  70. package/lib/project-tier/getProjectTier.ts +18 -0
  71. package/lib/project-tier/index.ts +3 -0
  72. package/lib/project-tier/setProjectTier.ts +14 -0
  73. package/lib/project-tier/types.ts +3 -0
  74. package/lib/security/net.ts +7 -4
  75. package/lib/start-options.ts +8 -1
  76. package/lib/tasks/finish.ts +2 -1
  77. package/lib/tasks/index.ts +10 -5
  78. package/lib/tasks/move-testing.ts +2 -2
  79. package/lib/tasks/state.ts +7 -1
  80. package/lib/test-temp.ts +30 -0
  81. package/lib/workspaces.ts +6 -2
  82. package/lib/worktree-sweep/caches.ts +6 -0
  83. package/lib/worktree-sweep/extras.ts +16 -8
  84. package/lib/worktree-sweep/report.ts +1 -0
  85. package/lib/worktree-sweep/settings.ts +2 -2
  86. package/lib/worktree-sweep/testTemp.ts +113 -0
  87. package/lib/worktree-sweep/types.ts +2 -0
  88. package/package.json +1 -1
  89. package/scripts/autodeploy.test.ts +2 -2
  90. package/scripts/check-bundle.test.ts +2 -2
  91. package/scripts/hooks/banned-terms.test.ts +3 -2
  92. package/scripts/hooks/staged-secrets.test.ts +2 -2
  93. package/scripts/lib/build-swap.test.ts +2 -3
  94. package/scripts/lib/wsl.test.ts +2 -3
  95. 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
+ }
@@ -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;
@@ -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(
@@ -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
  }
@@ -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
+ }
@@ -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;
@@ -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;
@@ -0,0 +1,38 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261018T2130_project_tier",
5
+ up: (db) => {
6
+ // A project's tier: "production" tasks pass every merge gate, "fast"
7
+ // ones merge as soon as their PR opens (orchestrator/fast-lane.ts). The
8
+ // LumifyHub and Workstak projects start production, in the same step as
9
+ // the column, so running this again never overwrites a tier Saad changed.
10
+ const cols = db.prepare(`PRAGMA table_info(projects)`).all() as {
11
+ name: string;
12
+ }[];
13
+ if (!cols.some((c) => c.name === "tier")) {
14
+ db.exec(
15
+ `ALTER TABLE projects ADD COLUMN tier TEXT NOT NULL DEFAULT 'fast'`
16
+ );
17
+ db.exec(
18
+ `UPDATE projects SET tier = 'production'
19
+ WHERE lower(name) LIKE 'lumifyhub%' OR lower(name) LIKE 'workstak%'
20
+ OR workspace_id IN (
21
+ SELECT id FROM workspaces WHERE lower(name) IN ('lumifyhub', 'workstak')
22
+ )`
23
+ );
24
+ }
25
+ // What the fast lane has done to a task: the PR it armed (so a disarm
26
+ // sticks) and the merge whose CI it still watches.
27
+ db.exec(`CREATE TABLE IF NOT EXISTS fast_lane_tasks (
28
+ session_id TEXT PRIMARY KEY,
29
+ workspace_id TEXT NOT NULL,
30
+ auto_armed_pr INTEGER,
31
+ merged_sha TEXT,
32
+ merged_pr INTEGER,
33
+ merged_at TEXT,
34
+ ci_checked_at TEXT,
35
+ ci_done_at TEXT
36
+ )`);
37
+ },
38
+ } satisfies Migration;
package/lib/db/types.ts CHANGED
@@ -175,6 +175,9 @@ export interface Group {
175
175
  created_at: string;
176
176
  }
177
177
 
178
+ // production: every merge gate applies. fast: a task's PR merges when it opens.
179
+ export type ProjectTier = "production" | "fast";
180
+
178
181
  export interface Project {
179
182
  id: string;
180
183
  name: string;
@@ -190,6 +193,7 @@ export interface Project {
190
193
  lh_board_id: string | null;
191
194
  lh_board_name: string | null;
192
195
  lh_board_page_id: string | null;
196
+ tier: ProjectTier;
193
197
  // What its tasks need from a machine (JSON array), and where that came from.
194
198
  machine_needs?: string | null;
195
199
  machine_needs_source?: string | null;
@@ -296,6 +300,9 @@ export interface Workspace {
296
300
  // Tasks are messaged about failing checks and reviews on their PRs
297
301
  // (orchestrator/pr-wake.ts).
298
302
  wake_tasks_on_pr: boolean;
303
+ // Its orchestrator's shell and file writes aren't checked, and it may merge
304
+ // directly (orchestrator/full-access.ts).
305
+ orch_full_access: boolean;
299
306
  created_at: string;
300
307
  }
301
308
 
@@ -2,9 +2,9 @@
2
2
  // worktrees cut from it the way AgentOS cuts them.
3
3
 
4
4
  import fs from "fs";
5
- import os from "os";
6
5
  import path from "path";
7
6
  import { gitSync } from "../git-exec";
7
+ import { tempDir } from "@/lib/test-temp";
8
8
 
9
9
  // Old enough that CI counts as settled on every commit.
10
10
  const env = { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" };
@@ -24,7 +24,7 @@ export function commitFile(cwd: string, file: string, text: string): string {
24
24
  export const WORKTREE_MARK = "aos-done-wt";
25
25
 
26
26
  export function makeRepo() {
27
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-done-repo-"));
27
+ const root = tempDir("aos-done-repo-");
28
28
  const remote = path.join(root, "remote.git");
29
29
  const repo = path.join(root, "repo");
30
30
  git(root, "init", "-q", "--bare", "-b", "main", remote);
package/lib/env-setup.ts CHANGED
@@ -180,7 +180,7 @@ export async function setupWorktree(options: {
180
180
 
181
181
  // 1. The project's config. A broken one is reported and nothing of it is
182
182
  // used: running an older file's setup instead would hide the mistake.
183
- const { config, source, error } = loadProjectConfig(sourcePath);
183
+ const { config, source, error, warning } = loadProjectConfig(sourcePath);
184
184
  if (error) {
185
185
  result.success = false;
186
186
  result.steps.push({
@@ -189,6 +189,13 @@ export async function setupWorktree(options: {
189
189
  success: false,
190
190
  error,
191
191
  });
192
+ } else if (warning) {
193
+ result.steps.push({
194
+ name: "Read agentos.json and .dispatch.json",
195
+ command: "read agentos.json + .dispatch.json",
196
+ success: true,
197
+ output: warning,
198
+ });
192
199
  }
193
200
 
194
201
  // 2. Copy env files, or exactly what the config's `copy` names
@@ -7,8 +7,7 @@
7
7
  */
8
8
 
9
9
  import fs from "fs";
10
- import os from "os";
11
- import path from "path";
10
+ import { agentosHome } from "../agentos-home";
12
11
  import { db } from "../db";
13
12
  import { gitSync } from "../git-exec";
14
13
  import { getSetting, putSetting } from "../settings-store";
@@ -80,12 +79,7 @@ export const recentBoots = (n = 6) =>
80
79
  .prepare(`SELECT * FROM health_boots ORDER BY started_at DESC LIMIT ?`)
81
80
  .all(n) as BootRow[];
82
81
 
83
- export const DEPLOY_RESTART_FILE = path.join(
84
- os.homedir(),
85
- ".agent-os",
86
- "autodeploy",
87
- "restarted"
88
- );
82
+ export const DEPLOY_RESTART_FILE = agentosHome("autodeploy", "restarted");
89
83
 
90
84
  // When autodeploy last restarted the server (it writes epoch seconds once the
91
85
  // server is back up), or null.