@cursor/july 0.1.86 → 0.1.88

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 (49) hide show
  1. package/dist/bin/agent-serve.js +7 -0
  2. package/dist/internal/advertise-tools.d.ts.map +1 -1
  3. package/dist/internal/advertise-tools.js +4 -2
  4. package/dist/internal/cli-docs.js +11 -0
  5. package/dist/internal/cloud-merge.d.ts +3 -1
  6. package/dist/internal/cloud-merge.d.ts.map +1 -1
  7. package/dist/internal/cloud-merge.js +10 -2
  8. package/dist/internal/cursor/backend-client.d.ts +4 -0
  9. package/dist/internal/cursor/backend-client.d.ts.map +1 -1
  10. package/dist/internal/cursor/backend-client.js +4 -0
  11. package/dist/internal/discovery.d.ts.map +1 -1
  12. package/dist/internal/discovery.js +87 -11
  13. package/dist/internal/docs-site.d.ts +13 -2
  14. package/dist/internal/docs-site.d.ts.map +1 -1
  15. package/dist/internal/docs-site.js +76 -13
  16. package/dist/internal/grokbot/runner.d.ts +61 -0
  17. package/dist/internal/grokbot/runner.d.ts.map +1 -0
  18. package/dist/internal/grokbot/runner.js +278 -0
  19. package/dist/internal/mcp-endpoint.js +4 -2
  20. package/dist/internal/mcp-host.d.ts +14 -1
  21. package/dist/internal/mcp-host.d.ts.map +1 -1
  22. package/dist/internal/mcp-host.js +41 -2
  23. package/dist/internal/runtime-dispatch-runner.d.ts +30 -0
  24. package/dist/internal/runtime-dispatch-runner.d.ts.map +1 -0
  25. package/dist/internal/runtime-dispatch-runner.js +60 -0
  26. package/dist/internal/sdk-runner.d.ts.map +1 -1
  27. package/dist/internal/sdk-runner.js +7 -0
  28. package/dist/internal/server.d.ts.map +1 -1
  29. package/dist/internal/server.js +24 -1
  30. package/dist/internal/session-engine.d.ts.map +1 -1
  31. package/dist/internal/session-engine.js +36 -18
  32. package/dist/types.d.ts +64 -2
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/bin/agent-serve.ts +8 -0
  36. package/src/internal/advertise-tools.ts +6 -0
  37. package/src/internal/cli-docs.ts +11 -0
  38. package/src/internal/cloud-merge.ts +12 -2
  39. package/src/internal/cursor/backend-client.ts +4 -0
  40. package/src/internal/discovery.ts +133 -13
  41. package/src/internal/docs-site.ts +83 -13
  42. package/src/internal/grokbot/runner.ts +361 -0
  43. package/src/internal/mcp-endpoint.ts +4 -0
  44. package/src/internal/mcp-host.ts +48 -0
  45. package/src/internal/runtime-dispatch-runner.ts +63 -0
  46. package/src/internal/sdk-runner.ts +9 -0
  47. package/src/internal/server.ts +32 -4
  48. package/src/internal/session-engine.ts +40 -15
  49. package/src/types.ts +65 -2
@@ -0,0 +1,361 @@
1
+ /**
2
+ * The grokbot runtime's remote client — a thin client like `"cloud"`.
3
+ * Turns execute on Cursor's hosted Grok Bot (Sand) harness (the Temporal
4
+ * turn workflow and the account's own computer), reached through the
5
+ * public `/v0/grokbot` session API.
6
+ *
7
+ * Sand delivery contract, preserved end to end: only content the agent
8
+ * delivers through SendToUser becomes the user-visible reply. The hosted
9
+ * transcript exposes those deliveries as `send-message` entries; the
10
+ * agent's internal monologue never appears in the transcript, so it can
11
+ * never leak into the session reply.
12
+ */
13
+
14
+ import { randomUUID } from "node:crypto";
15
+ import type { RunnerTurnRequest, TurnOutcome } from "../../types.js";
16
+ import { cursorBackendUrl } from "../cursor/credentials.js";
17
+ import type { AgentRunner } from "./../sdk-runner.js";
18
+
19
+ export interface GrokBotRunnerOptions {
20
+ /** Cursor API key. Defaults to the `CURSOR_API_KEY` environment variable. */
21
+ apiKey?: string;
22
+ /** Test seam. Defaults to global fetch. */
23
+ fetchImpl?: typeof fetch;
24
+ /** Test seam. Defaults to {@link cursorBackendUrl}. */
25
+ backendUrl?: string;
26
+ /** Test seam. Poll interval while a turn runs. */
27
+ pollIntervalMs?: number;
28
+ /** Test seam. Hard turn deadline. */
29
+ turnTimeoutMs?: number;
30
+ }
31
+
32
+ /** One transcript entry as `/v0/grokbot/sessions/:id/entries` exposes it. */
33
+ export interface GrokBotEntry {
34
+ seq: string;
35
+ updatedSeq: string;
36
+ kind: string;
37
+ role?: string;
38
+ text?: string;
39
+ createdAtMs: number;
40
+ }
41
+
42
+ interface GrokBotTurnStatus {
43
+ idle: boolean;
44
+ inFlight: boolean;
45
+ queued: number;
46
+ }
47
+
48
+ interface EntriesPage {
49
+ entries: GrokBotEntry[];
50
+ latestUpdatedSeq: string;
51
+ turn: GrokBotTurnStatus;
52
+ }
53
+
54
+ interface HostedAgent {
55
+ /** The `/v0/grokbot` session id (the hosted agent's durable id). */
56
+ remoteId: string;
57
+ /** Transcript cursor: newest `updatedSeq` this client has consumed. */
58
+ lastUpdatedSeq: bigint;
59
+ }
60
+
61
+ const DEFAULT_POLL_INTERVAL_MS = 750;
62
+ const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000;
63
+
64
+ /**
65
+ * Idle polls required after a send before the turn is considered settled
66
+ * when the workflow never reported busy — covers the window between the
67
+ * accepted dispatch and the workflow picking the signal up.
68
+ */
69
+ const MIN_IDLE_POLLS_WITHOUT_BUSY = 4;
70
+
71
+ class GrokBotApiError extends Error {
72
+ constructor(
73
+ readonly status: number,
74
+ readonly path: string,
75
+ body: string
76
+ ) {
77
+ super(
78
+ `Grok Bot API ${path} failed (HTTP ${status}): ${body.slice(0, 300)}`
79
+ );
80
+ this.name = "GrokBotApiError";
81
+ }
82
+ }
83
+
84
+ /** Minimal `/v0/grokbot` client: bearer API key, JSON in/out. */
85
+ class GrokBotApiClient {
86
+ private readonly fetchImpl: typeof fetch;
87
+ private readonly baseUrl: string;
88
+ private readonly apiKey: string;
89
+
90
+ constructor(options: GrokBotRunnerOptions) {
91
+ this.fetchImpl = options.fetchImpl ?? fetch;
92
+ this.baseUrl = (options.backendUrl ?? cursorBackendUrl()).replace(
93
+ /\/$/,
94
+ ""
95
+ );
96
+ const apiKey = options.apiKey ?? process.env.CURSOR_API_KEY;
97
+ if (apiKey === undefined || apiKey === "") {
98
+ throw new Error(
99
+ 'runtime: "grokbot" needs a Cursor API key (CURSOR_API_KEY or agent-sdk login) to reach the hosted Grok Bot harness.'
100
+ );
101
+ }
102
+ this.apiKey = apiKey;
103
+ }
104
+
105
+ private async request<T>(args: {
106
+ method: string;
107
+ path: string;
108
+ body?: unknown;
109
+ }): Promise<T> {
110
+ const headers: Record<string, string> = {
111
+ authorization: `Bearer ${this.apiKey}`,
112
+ };
113
+ if (args.body !== undefined) {
114
+ headers["content-type"] = "application/json";
115
+ }
116
+ const response = await this.fetchImpl(`${this.baseUrl}${args.path}`, {
117
+ method: args.method,
118
+ headers,
119
+ body: args.body === undefined ? undefined : JSON.stringify(args.body),
120
+ });
121
+ if (!response.ok) {
122
+ throw new GrokBotApiError(
123
+ response.status,
124
+ args.path,
125
+ await response.text().catch(() => "")
126
+ );
127
+ }
128
+ return (await response.json()) as T;
129
+ }
130
+
131
+ async createSession(args: {
132
+ name: string;
133
+ instructions: string | undefined;
134
+ }): Promise<{ id: string; latestUpdatedSeq: string }> {
135
+ return await this.request({
136
+ method: "POST",
137
+ path: "/v0/grokbot/sessions",
138
+ body: { name: args.name, instructions: args.instructions },
139
+ });
140
+ }
141
+
142
+ async sendMessage(args: {
143
+ sessionId: string;
144
+ text: string;
145
+ }): Promise<{ messageId: string; delivery: string }> {
146
+ return await this.request({
147
+ method: "POST",
148
+ path: `/v0/grokbot/sessions/${args.sessionId}/messages`,
149
+ body: { text: args.text, messageId: randomUUID() },
150
+ });
151
+ }
152
+
153
+ async listEntries(args: {
154
+ sessionId: string;
155
+ afterUpdatedSeq: bigint;
156
+ }): Promise<EntriesPage> {
157
+ return await this.request({
158
+ method: "GET",
159
+ path: `/v0/grokbot/sessions/${args.sessionId}/entries?afterUpdatedSeq=${args.afterUpdatedSeq}`,
160
+ });
161
+ }
162
+
163
+ async interrupt(args: { sessionId: string; reason: string }): Promise<void> {
164
+ await this.request({
165
+ method: "POST",
166
+ path: `/v0/grokbot/sessions/${args.sessionId}/interrupt`,
167
+ body: { reason: args.reason },
168
+ });
169
+ }
170
+ }
171
+
172
+ export class GrokBotRunner implements AgentRunner {
173
+ /**
174
+ * One hosted Grok Bot agent per SDK agent name — the backend
175
+ * get-or-creates by name, so every SDK session forwards into the same
176
+ * hosted agent and conversation, and the Grok Bot app shows one agent
177
+ * rather than one per session.
178
+ */
179
+ private readonly agents = new Map<string, HostedAgent>();
180
+ /**
181
+ * Per-hosted-agent turn chain. Sessions share one hosted conversation
182
+ * and one transcript cursor, so turns run one at a time per agent:
183
+ * deliveries attribute to the turn that sent them, and an abort only
184
+ * interrupts its own turn — mirroring the hosted workflow's own queue.
185
+ */
186
+ private readonly turns = new Map<string, Promise<unknown>>();
187
+ private client: GrokBotApiClient | undefined;
188
+
189
+ constructor(private readonly options: GrokBotRunnerOptions = {}) {}
190
+
191
+ async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
192
+ if (request.runtime !== "grokbot") {
193
+ throw new Error(
194
+ `Session ${request.sessionId} is a ${request.runtime} turn, which the Grok Bot runner cannot execute.`
195
+ );
196
+ }
197
+ if (request.images !== undefined && request.images.length > 0) {
198
+ // Attachments ride the hosted box's upload path, which the /v0
199
+ // session API does not expose yet. Refuse rather than silently
200
+ // dropping what the user attached.
201
+ throw new Error(
202
+ "Image attachments are not supported on grokbot turns yet."
203
+ );
204
+ }
205
+ const name = hostedAgentName(request);
206
+ const previous = this.turns.get(name) ?? Promise.resolve();
207
+ const run = previous.then(() => this.executeTurn(request));
208
+ this.turns.set(
209
+ name,
210
+ run.catch(() => {})
211
+ );
212
+ return await run;
213
+ }
214
+
215
+ private async executeTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
216
+ const signal = request.signal;
217
+ if (signal?.aborted === true) {
218
+ return { status: "cancelled" };
219
+ }
220
+ const client = this.ensureClient();
221
+ const entry = await this.getAgent(client, request);
222
+
223
+ const send = await client.sendMessage({
224
+ sessionId: entry.remoteId,
225
+ text: request.prompt,
226
+ });
227
+ if (
228
+ send.delivery !== "accepted_temporal" &&
229
+ send.delivery !== "duplicate"
230
+ ) {
231
+ throw new Error(
232
+ `The hosted Grok Bot harness did not accept the turn (delivery: ${send.delivery}).`
233
+ );
234
+ }
235
+
236
+ const pollIntervalMs =
237
+ this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
238
+ const turnTimeoutMs = this.options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
239
+ const deadline = Date.now() + turnTimeoutMs;
240
+ const delivered: string[] = [];
241
+ let sawBusy = false;
242
+ let idlePolls = 0;
243
+
244
+ while (true) {
245
+ // On abort: interrupt the hosted run, drain entries once so a reply
246
+ // that already landed still streams out, then cancel.
247
+ const aborted = isAborted(signal);
248
+ if (aborted) {
249
+ await client
250
+ .interrupt({
251
+ sessionId: entry.remoteId,
252
+ reason: "The user sent a new message.",
253
+ })
254
+ .catch(() => {});
255
+ }
256
+ const page = await client.listEntries({
257
+ sessionId: entry.remoteId,
258
+ afterUpdatedSeq: entry.lastUpdatedSeq,
259
+ });
260
+ entry.lastUpdatedSeq = BigInt(page.latestUpdatedSeq);
261
+ for (const item of page.entries) {
262
+ if (item.kind !== "send-message" || item.text === undefined) {
263
+ continue;
264
+ }
265
+ const delta = delivered.length === 0 ? item.text : `\n\n${item.text}`;
266
+ delivered.push(item.text);
267
+ // The emit callback may be async; a rejection must never escape
268
+ // the poll loop.
269
+ void Promise.resolve(
270
+ request.onUpdate({ type: "text-delta", text: delta })
271
+ ).catch(() => {});
272
+ }
273
+ if (aborted) {
274
+ return { status: "cancelled" };
275
+ }
276
+ if (page.turn.inFlight || page.turn.queued > 0) {
277
+ sawBusy = true;
278
+ } else if (page.turn.idle) {
279
+ idlePolls += 1;
280
+ if (sawBusy || idlePolls >= MIN_IDLE_POLLS_WITHOUT_BUSY) {
281
+ return {
282
+ status: "finished",
283
+ result: delivered.length === 0 ? undefined : delivered.join("\n\n"),
284
+ };
285
+ }
286
+ }
287
+ if (Date.now() >= deadline) {
288
+ throw new Error(
289
+ `The grokbot turn for session ${request.sessionId} did not settle within ${Math.round(turnTimeoutMs / 60_000)} minutes.`
290
+ );
291
+ }
292
+ await this.sleep(pollIntervalMs, signal);
293
+ }
294
+ }
295
+
296
+ async dispose(): Promise<void> {
297
+ // Hosted agents live server-side (the account's Grok Bot agents);
298
+ // nothing in this process holds conversation state.
299
+ this.agents.clear();
300
+ }
301
+
302
+ private ensureClient(): GrokBotApiClient {
303
+ this.client ??= new GrokBotApiClient(this.options);
304
+ return this.client;
305
+ }
306
+
307
+ private async getAgent(
308
+ client: GrokBotApiClient,
309
+ request: RunnerTurnRequest
310
+ ): Promise<HostedAgent> {
311
+ const name = hostedAgentName(request);
312
+ const existing = this.agents.get(name);
313
+ if (existing !== undefined) {
314
+ return existing;
315
+ }
316
+ // Get-or-create: a reconnect (new process, same agent name) lands on
317
+ // the existing hosted agent. The returned tail seq starts the cursor
318
+ // after the hosted conversation's history so an old transcript never
319
+ // replays into this turn's reply.
320
+ const created = await client.createSession({
321
+ name,
322
+ instructions: request.instructions,
323
+ });
324
+ const entry: HostedAgent = {
325
+ remoteId: created.id,
326
+ lastUpdatedSeq: BigInt(created.latestUpdatedSeq),
327
+ };
328
+ this.agents.set(name, entry);
329
+ return entry;
330
+ }
331
+
332
+ private async sleep(
333
+ ms: number,
334
+ signal: AbortSignal | undefined
335
+ ): Promise<void> {
336
+ await new Promise<void>((resolve) => {
337
+ const timer = setTimeout(() => {
338
+ signal?.removeEventListener("abort", onAbort);
339
+ resolve();
340
+ }, ms);
341
+ const onAbort = (): void => {
342
+ clearTimeout(timer);
343
+ resolve();
344
+ };
345
+ signal?.addEventListener("abort", onAbort, { once: true });
346
+ });
347
+ }
348
+ }
349
+
350
+ function hostedAgentName(request: RunnerTurnRequest): string {
351
+ return request.agentName ?? "Agent SDK agent";
352
+ }
353
+
354
+ /**
355
+ * `AbortSignal.aborted` is readonly to TypeScript, so an inline recheck
356
+ * after an await narrows to the stale pre-await value; the function
357
+ * boundary keeps the recheck honest.
358
+ */
359
+ function isAborted(signal: AbortSignal | undefined): boolean {
360
+ return signal?.aborted === true;
361
+ }
@@ -304,6 +304,10 @@ function buildConnectionBridgeMcpServer(
304
304
  ...(tool.outputSchema === undefined
305
305
  ? {}
306
306
  : { outputSchema: tool.outputSchema }),
307
+ // Already bounded to the MCP-spec fields at listing time (mcp-host).
308
+ ...(tool.annotations === undefined
309
+ ? {}
310
+ : { annotations: tool.annotations }),
307
311
  })),
308
312
  };
309
313
  });
@@ -20,6 +20,7 @@ import type {
20
20
  HostMcpRegistry,
21
21
  HostMcpToolInfo,
22
22
  JsonObject,
23
+ McpToolAnnotations,
23
24
  } from "../types.js";
24
25
  import { CLI_COMMAND_NAME as CLI } from "./distribution.js";
25
26
  import { createHostMcpOAuthProvider } from "./mcp-oauth.js";
@@ -213,12 +214,58 @@ export function isStaleMcpSessionError(error: unknown): boolean {
213
214
  return code === 404;
214
215
  }
215
216
 
217
+ /** Longest server-supplied annotation title carried off a listing. */
218
+ const MAX_TOOL_ANNOTATION_TITLE_LENGTH = 256;
219
+
220
+ /**
221
+ * Bound a server's `Tool.annotations` to the MCP-spec fields: the four
222
+ * boolean hints plus a length-capped `title`. A server cannot stuff
223
+ * arbitrary payloads into every listing, and malformed values (a non-boolean
224
+ * hint, a non-object) behave like absent ones so consumers fail closed.
225
+ *
226
+ * Mirrors `@anysphere/mcp-core/mcp-tool-annotations` (`toolAnnotationsJsonField`),
227
+ * which this package cannot import: it publishes to npm and cannot take a
228
+ * workspace-only runtime dependency. Keep the two in sync.
229
+ *
230
+ * Exported for unit tests.
231
+ */
232
+ export function boundedToolAnnotations(
233
+ annotations: unknown
234
+ ): McpToolAnnotations | undefined {
235
+ if (
236
+ typeof annotations !== "object" ||
237
+ annotations === null ||
238
+ Array.isArray(annotations)
239
+ ) {
240
+ return undefined;
241
+ }
242
+ const record = annotations as Record<string, unknown>;
243
+ const bounded: McpToolAnnotations = {};
244
+ if (typeof record.title === "string" && record.title.length > 0) {
245
+ bounded.title = record.title.slice(0, MAX_TOOL_ANNOTATION_TITLE_LENGTH);
246
+ }
247
+ for (const key of [
248
+ "readOnlyHint",
249
+ "destructiveHint",
250
+ "idempotentHint",
251
+ "openWorldHint",
252
+ ] as const) {
253
+ const value = record[key];
254
+ if (typeof value === "boolean") {
255
+ bounded[key] = value;
256
+ }
257
+ }
258
+ return Object.keys(bounded).length === 0 ? undefined : bounded;
259
+ }
260
+
216
261
  function toToolInfo(tool: {
217
262
  name: string;
218
263
  description?: string;
219
264
  inputSchema?: unknown;
220
265
  outputSchema?: unknown;
266
+ annotations?: unknown;
221
267
  }): HostMcpToolInfo {
268
+ const annotations = boundedToolAnnotations(tool.annotations);
222
269
  return {
223
270
  name: tool.name,
224
271
  ...(tool.description === undefined
@@ -230,6 +277,7 @@ function toToolInfo(tool: {
230
277
  ...(tool.outputSchema === undefined
231
278
  ? {}
232
279
  : { outputSchema: tool.outputSchema as JsonObject }),
280
+ ...(annotations === undefined ? {} : { annotations }),
233
281
  };
234
282
  }
235
283
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Routes each turn to the runner that owns its runtime: local and cloud
3
+ * turns stay on the Cursor SDK runner, grokbot turns go to the Grok Bot
4
+ * remote client (`internal/grokbot/runner.ts`), which drives the hosted
5
+ * Grok Bot (Sand) harness through the public `/v0/grokbot` session API.
6
+ * Both runners ship in the published package.
7
+ */
8
+
9
+ import type { RunnerTurnRequest, TurnOutcome } from "../types.js";
10
+ import type { GrokBotRunnerOptions } from "./grokbot/runner.js";
11
+ import { GrokBotRunner } from "./grokbot/runner.js";
12
+ import type {
13
+ AgentRunner,
14
+ PrewarmOutcome,
15
+ RunnerPrewarmRequest,
16
+ } from "./sdk-runner.js";
17
+
18
+ export interface RuntimeDispatchingRunnerOptions {
19
+ sdkRunner: AgentRunner;
20
+ grokbot?: GrokBotRunnerOptions;
21
+ /** Test seam — replaces the real Grok Bot remote client. */
22
+ createGrokBotRunner?: (options: GrokBotRunnerOptions) => AgentRunner;
23
+ }
24
+
25
+ export class RuntimeDispatchingRunner implements AgentRunner {
26
+ private grokbotRunner: AgentRunner | undefined;
27
+
28
+ constructor(private readonly options: RuntimeDispatchingRunnerOptions) {}
29
+
30
+ /**
31
+ * The Grok Bot remote client, created on first use; a missing API key
32
+ * surfaces on the first grokbot turn, not at serve start.
33
+ */
34
+ ensureGrokBotRunner(): AgentRunner {
35
+ const options = this.options.grokbot ?? {};
36
+ this.grokbotRunner ??=
37
+ this.options.createGrokBotRunner?.(options) ?? new GrokBotRunner(options);
38
+ return this.grokbotRunner;
39
+ }
40
+
41
+ async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
42
+ if (request.runtime === "grokbot") {
43
+ return this.ensureGrokBotRunner().runTurn(request);
44
+ }
45
+ return this.options.sdkRunner.runTurn(request);
46
+ }
47
+
48
+ async prewarm(request: RunnerPrewarmRequest): Promise<PrewarmOutcome> {
49
+ // Prewarming is a local-harness concern; the engine only calls it for
50
+ // local-runtime agents.
51
+ return (
52
+ this.options.sdkRunner.prewarm?.(request) ?? {
53
+ warmed: false,
54
+ reason: "the Cursor SDK runner does not support prewarming",
55
+ }
56
+ );
57
+ }
58
+
59
+ async dispose(): Promise<void> {
60
+ await this.options.sdkRunner.dispose?.();
61
+ await this.grokbotRunner?.dispose?.();
62
+ }
63
+ }
@@ -199,6 +199,15 @@ export class CursorSdkRunner implements AgentRunner {
199
199
  }
200
200
 
201
201
  async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
202
+ // Fail closed: this runner only knows the Cursor SDK's local and cloud
203
+ // harnesses. A grokbot turn reaching it means the serve wiring skipped
204
+ // the Grok Bot runner — running it on the local harness would silently
205
+ // swap the box (and the SendToUser contract) out from under the agent.
206
+ if (request.runtime === "grokbot") {
207
+ throw new Error(
208
+ `Session ${request.sessionId} is a grokbot turn, which the Cursor SDK runner cannot execute. Serve must route grokbot turns to the Grok Bot runner.`
209
+ );
210
+ }
202
211
  let sawUpdate = false;
203
212
  const tracked: RunnerTurnRequest = {
204
213
  ...request,
@@ -175,6 +175,7 @@ import {
175
175
  validateMountConnectionsConfig,
176
176
  } from "./resolved-connections.js";
177
177
  import { Router } from "./router.js";
178
+ import { RuntimeDispatchingRunner } from "./runtime-dispatch-runner.js";
178
179
  import { ScheduleRunner, UnknownScheduleError } from "./schedule-runner.js";
179
180
  import { type AgentRunner, CursorSdkRunner } from "./sdk-runner.js";
180
181
  import {
@@ -493,12 +494,39 @@ export async function startServer(
493
494
  "[agent-sdk] warning: --allow-anonymous admits every HTTP caller as the same principal; use only on a trusted network (e.g. Tailscale) and prefer --bearer-token for shared hosts"
494
495
  );
495
496
  }
496
- const runner =
497
+ const sdkRunner =
497
498
  options.runner ??
498
- new CursorSdkRunner({
499
- ...(accountKey === undefined ? {} : { apiKey: accountKey.apiKey }),
500
- logger,
499
+ new CursorSdkRunner({ apiKey: accountKey?.apiKey, logger });
500
+ // Grok Bot turns run on Cursor's hosted Grok Bot (Sand) harness through
501
+ // the /v0/grokbot session API; the SDK side is a thin remote client. The
502
+ // dispatching runner is only interposed when a mount needs it. An
503
+ // injected test runner receives every turn, grokbot included.
504
+ const hasGrokBotMounts = mounts.some(
505
+ (mount) => mount.project.agent.runtime === "grokbot"
506
+ );
507
+ // Fail closed like cursor-account GitHub/MCP: grokbot turns execute on the
508
+ // signed-in account's hosted computer, so anonymous admission needs its
509
+ // own opt-in.
510
+ if (hasGrokBotMounts && options.allowAnonymous === true) {
511
+ if (options.allowAnonymousGrokbot !== true) {
512
+ throw new Error(
513
+ 'runtime: "grokbot" cannot be served with --allow-anonymous because admitted callers could drive the signed-in account\'s hosted Grok Bot computer. If every admitted caller is trusted (e.g. behind an SSO proxy), opt in with --allow-anonymous-grokbot.'
514
+ );
515
+ }
516
+ logger(
517
+ "[agent-sdk] warning: --allow-anonymous-grokbot — every admitted HTTP caller can drive turns on the signed-in account's hosted Grok Bot computer; serve this only behind a trusted boundary (e.g. an SSO proxy)"
518
+ );
519
+ }
520
+ let runner: AgentRunner = sdkRunner;
521
+ if (hasGrokBotMounts && options.runner === undefined) {
522
+ runner = new RuntimeDispatchingRunner({
523
+ sdkRunner,
524
+ grokbot: { apiKey: accountKey?.apiKey },
501
525
  });
526
+ logger(
527
+ "[agent-sdk] grokbot runtime: remote client (turns run on the hosted Grok Bot harness)"
528
+ );
529
+ }
502
530
 
503
531
  const router = new Router();
504
532
  const runtimes: MountedRuntime[] = [];