@cursor/july 0.1.20 → 0.1.21

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 (212) hide show
  1. package/dist/bin/agent-serve.js +0 -0
  2. package/dist/channels/github/instrument.d.ts +20 -0
  3. package/dist/channels/github/instrument.d.ts.map +1 -0
  4. package/dist/channels/slack/api.d.ts +2 -0
  5. package/dist/channels/slack/api.d.ts.map +1 -1
  6. package/dist/channels/slack/api.js +3 -0
  7. package/dist/channels/slack/bot-mentions.d.ts +106 -0
  8. package/dist/channels/slack/bot-mentions.d.ts.map +1 -0
  9. package/dist/channels/slack/bot-mentions.js +243 -0
  10. package/dist/channels/slack/dispatch.d.ts +6 -0
  11. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  12. package/dist/channels/slack/dispatch.js +24 -3
  13. package/dist/channels/slack/inbound.d.ts +10 -1
  14. package/dist/channels/slack/inbound.d.ts.map +1 -1
  15. package/dist/channels/slack/inbound.js +12 -4
  16. package/dist/channels/slack/index.d.ts +1 -0
  17. package/dist/channels/slack/index.d.ts.map +1 -1
  18. package/dist/channels/slack/index.js +1 -0
  19. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  20. package/dist/channels/slack/slack-channel.js +61 -22
  21. package/dist/channels/slack/types.d.ts +58 -0
  22. package/dist/channels/slack/types.d.ts.map +1 -1
  23. package/dist/docs/404.html +1 -1
  24. package/dist/docs/ab.html +2 -2
  25. package/dist/docs/assets/{app.T-5ImnM3.js → app.jDxLzWv4.js} +1 -1
  26. package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +1 -0
  27. package/dist/docs/assets/chunks/{VPLocalSearchBox.OMGMWH6H.js → VPLocalSearchBox.Y6bDR1-a.js} +1 -1
  28. package/dist/docs/assets/chunks/{theme.DLg4yB4D.js → theme.CLazCWlJ.js} +2 -2
  29. package/dist/docs/building-with-agents.html +2 -2
  30. package/dist/docs/concepts.html +2 -2
  31. package/dist/docs/deployment.html +2 -2
  32. package/dist/docs/evals.html +2 -2
  33. package/dist/docs/example-agents/approval-buddy.html +2 -2
  34. package/dist/docs/example-agents/benny.html +2 -2
  35. package/dist/docs/example-agents/bugbot.html +2 -2
  36. package/dist/docs/example-agents/codebase-wiki.html +2 -2
  37. package/dist/docs/example-agents/codeowners-review.html +2 -2
  38. package/dist/docs/example-agents/concierge.html +2 -2
  39. package/dist/docs/example-agents/fsd.html +2 -2
  40. package/dist/docs/example-agents/index.html +2 -2
  41. package/dist/docs/example-agents/knowledge-base.html +2 -2
  42. package/dist/docs/example-agents/oncall.html +2 -2
  43. package/dist/docs/example-agents/security-reviewer.html +2 -2
  44. package/dist/docs/example-agents/slack-agent.html +2 -2
  45. package/dist/docs/example-agents/weather-agent.html +2 -2
  46. package/dist/docs/guides/agent-to-agent.html +2 -2
  47. package/dist/docs/guides/cloud-runtime.html +2 -2
  48. package/dist/docs/guides/github.html +2 -2
  49. package/dist/docs/guides/human-in-the-loop.html +2 -2
  50. package/dist/docs/guides/mcp-oauth.html +2 -2
  51. package/dist/docs/guides/slack.html +2 -2
  52. package/dist/docs/guides/webhooks.html +2 -2
  53. package/dist/docs/hillclimbing.html +2 -2
  54. package/dist/docs/index.html +2 -2
  55. package/dist/docs/quickstart.html +2 -2
  56. package/dist/docs/reference/agent-config.html +2 -2
  57. package/dist/docs/reference/channels.html +2 -2
  58. package/dist/docs/reference/cli.html +2 -2
  59. package/dist/docs/reference/connections.html +2 -2
  60. package/dist/docs/reference/hooks.html +2 -2
  61. package/dist/docs/reference/http-api.html +2 -2
  62. package/dist/docs/reference/instructions.html +2 -2
  63. package/dist/docs/reference/playground.html +2 -2
  64. package/dist/docs/reference/project-layout.html +2 -2
  65. package/dist/docs/reference/prompt.html +2 -2
  66. package/dist/docs/reference/schedules.html +2 -2
  67. package/dist/docs/reference/sessions.html +2 -2
  68. package/dist/docs/reference/skills.html +2 -2
  69. package/dist/docs/reference/subagents.html +2 -2
  70. package/dist/docs/reference/tools.html +2 -2
  71. package/dist/docs/scaffolding-agents.html +2 -2
  72. package/dist/docs/storage.html +2 -2
  73. package/dist/docs/troubleshooting.html +2 -2
  74. package/dist/internal/json-dir-store.d.ts +32 -0
  75. package/dist/internal/json-dir-store.d.ts.map +1 -0
  76. package/dist/internal/json-dir-store.js +100 -0
  77. package/dist/internal/resolved-connections.d.ts +1 -1
  78. package/dist/internal/resolved-connections.d.ts.map +1 -1
  79. package/dist/internal/resolved-connections.js +10 -0
  80. package/dist/internal/session-engine.d.ts +4 -1
  81. package/dist/internal/session-engine.d.ts.map +1 -1
  82. package/dist/internal/session-engine.js +13 -2
  83. package/dist/playground/assets/index-CjOQ4hN9.css +1 -0
  84. package/dist/playground/assets/index-dshZQJCp.js +85 -0
  85. package/dist/playground/index.html +2 -2
  86. package/dist/types.d.ts +7 -0
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js +9 -0
  89. package/package.json +24 -24
  90. package/src/bin/agent-serve.version.test.ts +62 -0
  91. package/src/channels/github/api.test.ts +64 -0
  92. package/src/channels/github/auth.test.ts +105 -0
  93. package/src/channels/github/cursor-account.test.ts +204 -0
  94. package/src/channels/github/forward.test.ts +457 -0
  95. package/src/channels/github/github.test.ts +937 -0
  96. package/src/channels/github/replay.test.ts +179 -0
  97. package/src/channels/slack/api.post-message.test.ts +148 -0
  98. package/src/channels/slack/api.ts +5 -0
  99. package/src/channels/slack/approvals.test.ts +328 -0
  100. package/src/channels/slack/block-actions.test.ts +452 -0
  101. package/src/channels/slack/bot-mentions.test.ts +217 -0
  102. package/src/channels/slack/bot-mentions.ts +315 -0
  103. package/src/channels/slack/channel-watch.test.ts +363 -0
  104. package/src/channels/slack/cursor-account.test.ts +253 -0
  105. package/src/channels/slack/defaults.final-post.test.ts +182 -0
  106. package/src/channels/slack/dispatch.test.ts +795 -0
  107. package/src/channels/slack/dispatch.ts +27 -1
  108. package/src/channels/slack/eval-directive.test.ts +273 -0
  109. package/src/channels/slack/inbound.ts +25 -4
  110. package/src/channels/slack/index.ts +1 -0
  111. package/src/channels/slack/message-body.test.ts +54 -0
  112. package/src/channels/slack/nudge-store.test.ts +143 -0
  113. package/src/channels/slack/slack-channel.ts +66 -8
  114. package/src/channels/slack/slack.test.ts +391 -0
  115. package/src/channels/slack/stop.test.ts +23 -0
  116. package/src/channels/slack/thread-context.test.ts +202 -0
  117. package/src/channels/slack/types.ts +60 -0
  118. package/src/evals/assertions.test.ts +580 -0
  119. package/src/evals/expect.test.ts +144 -0
  120. package/src/evals/judge.test.ts +181 -0
  121. package/src/evals/loaders.test.ts +132 -0
  122. package/src/evals/matchers.test.ts +95 -0
  123. package/src/evals/reporters.test.ts +303 -0
  124. package/src/evals/run-facts.test.ts +259 -0
  125. package/src/internal/ab-snapshot.test.ts +325 -0
  126. package/src/internal/approval-gate.test.ts +49 -0
  127. package/src/internal/approvals.integration.test.ts +383 -0
  128. package/src/internal/authored-loaders.test.ts +31 -0
  129. package/src/internal/builtin-tools/reminders.test.ts +201 -0
  130. package/src/internal/channel-route-schema.test.ts +294 -0
  131. package/src/internal/chat-attach.test.ts +262 -0
  132. package/src/internal/cli-deploy.test.ts +1991 -0
  133. package/src/internal/cli-mcp.test.ts +789 -0
  134. package/src/internal/cli-skills.test.ts +133 -0
  135. package/src/internal/cli-slack.test.ts +1647 -0
  136. package/src/internal/cloud-merge.test.ts +74 -0
  137. package/src/internal/cron.test.ts +22 -0
  138. package/src/internal/cursor/account-mcp.test.ts +807 -0
  139. package/src/internal/cursor/backend-client.test.ts +591 -0
  140. package/src/internal/cursor/credentials.test.ts +351 -0
  141. package/src/internal/cursor/github-credentials.test.ts +136 -0
  142. package/src/internal/cursor-account-mcp-auth.test.ts +310 -0
  143. package/src/internal/cursor-account.integration.test.ts +441 -0
  144. package/src/internal/cursor-event-relay.test.ts +746 -0
  145. package/src/internal/cursor-github-credentials.integration.test.ts +271 -0
  146. package/src/internal/cursor-slack-relay.test.ts +525 -0
  147. package/src/internal/deploy-source.test.ts +111 -0
  148. package/src/internal/discovery.builtin-tools.test.ts +94 -0
  149. package/src/internal/discovery.concurrency.test.ts +60 -0
  150. package/src/internal/discovery.cursor-account.test.ts +133 -0
  151. package/src/internal/discovery.cwd.test.ts +83 -0
  152. package/src/internal/discovery.hosting.test.ts +80 -0
  153. package/src/internal/discovery.identity.test.ts +44 -0
  154. package/src/internal/docs-site.test.ts +66 -0
  155. package/src/internal/duration.test.ts +29 -0
  156. package/src/internal/eval-judge-model.test.ts +187 -0
  157. package/src/internal/eval-run-store.cancel.test.ts +142 -0
  158. package/src/internal/eval-run-store.storage.test.ts +211 -0
  159. package/src/internal/eval-runner.http.test.ts +403 -0
  160. package/src/internal/eval-runner.run.test.ts +928 -0
  161. package/src/internal/evals-client.test.ts +307 -0
  162. package/src/internal/event-mapper.test.ts +243 -0
  163. package/src/internal/github-fanout.test.ts +213 -0
  164. package/src/internal/handleAgentServeTrigger.test.ts +179 -0
  165. package/src/internal/host-kv.test.ts +82 -0
  166. package/src/internal/host-platforms.test.ts +126 -0
  167. package/src/internal/http-channel.test.ts +402 -0
  168. package/src/internal/init-project.test.ts +269 -0
  169. package/src/internal/install-cursor-skills.test.ts +262 -0
  170. package/src/internal/local-env.test.ts +100 -0
  171. package/src/internal/log-ring.test.ts +31 -0
  172. package/src/internal/logs-client.test.ts +350 -0
  173. package/src/internal/mcp-endpoint.test.ts +436 -0
  174. package/src/internal/mcp-host.test.ts +298 -0
  175. package/src/internal/mcp-oauth.test.ts +148 -0
  176. package/src/internal/net.test.ts +17 -0
  177. package/src/internal/peer-connections.test.ts +128 -0
  178. package/src/internal/peer-mcp.integration.test.ts +289 -0
  179. package/src/internal/playground/toolchain.test.ts +53 -0
  180. package/src/internal/playground-cli.test.ts +187 -0
  181. package/src/internal/playground-proxy.test.ts +376 -0
  182. package/src/internal/prompt-context.integration.test.ts +232 -0
  183. package/src/internal/prompt-context.test.ts +127 -0
  184. package/src/internal/reminder-runner.test.ts +390 -0
  185. package/src/internal/reminder-store.test.ts +53 -0
  186. package/src/internal/request-headers.test.ts +27 -0
  187. package/src/internal/resolve-prod-target.test.ts +787 -0
  188. package/src/internal/resolved-connections.test.ts +295 -0
  189. package/src/internal/resolved-connections.ts +18 -1
  190. package/src/internal/router.test.ts +57 -0
  191. package/src/internal/sdk-runner.test.ts +290 -0
  192. package/src/internal/session-engine.coalesce.test.ts +169 -0
  193. package/src/internal/session-engine.concurrency.test.ts +250 -0
  194. package/src/internal/session-engine.host-oauth-mcp.test.ts +110 -0
  195. package/src/internal/session-engine.interrupt.test.ts +577 -0
  196. package/src/internal/session-engine.storage.test.ts +547 -0
  197. package/src/internal/session-engine.ts +15 -1
  198. package/src/internal/session-urls.test.ts +28 -0
  199. package/src/internal/sessions-client.test.ts +518 -0
  200. package/src/internal/storage-coordinator.test.ts +517 -0
  201. package/src/internal/tool-call.test.ts +458 -0
  202. package/src/internal/tool-result.test.ts +52 -0
  203. package/src/internal/trajectory.approvals.test.ts +83 -0
  204. package/src/internal/trajectory.subagents.test.ts +198 -0
  205. package/src/internal/turn-governor.test.ts +137 -0
  206. package/src/internal/update-check.test.ts +485 -0
  207. package/src/internal/workspace.test.ts +81 -0
  208. package/src/storage-backends/cursor-hosted.test.ts +121 -0
  209. package/src/types.ts +12 -0
  210. package/dist/docs/assets/chunks/@localSearchIndexroot.CvejISXF.js +0 -1
  211. package/dist/playground/assets/index-CidizGZv.css +0 -1
  212. package/dist/playground/assets/index-CuiAKZaP.js +0 -85
@@ -0,0 +1,351 @@
1
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ clearStoredCredentials,
7
+ credentialsFilePath,
8
+ cursorBackendUrl,
9
+ cursorExternalApiUrl,
10
+ pollForLoginTokens,
11
+ readStoredCredentials,
12
+ resolveApiKey,
13
+ startBrowserLogin,
14
+ writeStoredCredentials,
15
+ } from "./credentials.js";
16
+
17
+ let configDir: string;
18
+ const savedEnv: Record<string, string | undefined> = {};
19
+
20
+ beforeEach(async () => {
21
+ configDir = await mkdtemp(join(tmpdir(), "agent-serve-creds-"));
22
+ for (const key of [
23
+ "AGENT_SERVE_CONFIG_DIR",
24
+ "CURSOR_API_KEY",
25
+ "CURSOR_API_BASE_URL",
26
+ "AGENT_SERVE_CURSOR_BACKEND_URL",
27
+ "CURSOR_WEBSITE_URL",
28
+ ]) {
29
+ savedEnv[key] = process.env[key];
30
+ delete process.env[key];
31
+ }
32
+ process.env.AGENT_SERVE_CONFIG_DIR = configDir;
33
+ });
34
+
35
+ afterEach(async () => {
36
+ for (const [key, value] of Object.entries(savedEnv)) {
37
+ if (value === undefined) {
38
+ delete process.env[key];
39
+ } else {
40
+ process.env[key] = value;
41
+ }
42
+ }
43
+ await rm(configDir, { recursive: true, force: true });
44
+ });
45
+
46
+ describe("base URLs", () => {
47
+ it("defaults the external API host separately from the backend", () => {
48
+ expect(cursorBackendUrl()).toBe("https://api2.cursor.sh");
49
+ expect(cursorExternalApiUrl()).toBe("https://api.cursor.com");
50
+ });
51
+
52
+ it("CURSOR_API_BASE_URL overrides both for local dev", () => {
53
+ process.env.CURSOR_API_BASE_URL = "http://localhost:8888/";
54
+ expect(cursorBackendUrl()).toBe("http://localhost:8888");
55
+ expect(cursorExternalApiUrl()).toBe("http://localhost:8888");
56
+ });
57
+
58
+ it("AGENT_SERVE_CURSOR_BACKEND_URL re-pins only the backend family (hosted pods)", () => {
59
+ process.env.CURSOR_API_BASE_URL = "https://api.cursor.com";
60
+ process.env.AGENT_SERVE_CURSOR_BACKEND_URL = "https://api2.cursor.sh/";
61
+ expect(cursorBackendUrl()).toBe("https://api2.cursor.sh");
62
+ expect(cursorExternalApiUrl()).toBe("https://api.cursor.com");
63
+ });
64
+ });
65
+
66
+ describe("stored credentials", () => {
67
+ it("round-trips through the credentials file with 0600 permissions", async () => {
68
+ await writeStoredCredentials({
69
+ backendUrl: "https://api2.cursor.sh",
70
+ apiKey: "crsr_test",
71
+ });
72
+ expect(await readStoredCredentials()).toEqual({
73
+ backendUrl: "https://api2.cursor.sh",
74
+ apiKey: "crsr_test",
75
+ });
76
+ const mode = (await stat(credentialsFilePath())).mode & 0o777;
77
+ expect(mode).toBe(0o600);
78
+ expect(await readFile(credentialsFilePath(), "utf8")).toContain(
79
+ "crsr_test"
80
+ );
81
+
82
+ expect(await clearStoredCredentials()).toBe(true);
83
+ expect(await readStoredCredentials()).toBeUndefined();
84
+ expect(await clearStoredCredentials()).toBe(false);
85
+ });
86
+
87
+ it("treats a corrupt credentials file as signed out", async () => {
88
+ await writeStoredCredentials({
89
+ backendUrl: "https://api2.cursor.sh",
90
+ apiKey: "crsr_test",
91
+ });
92
+ const { writeFile } = await import("node:fs/promises");
93
+ await writeFile(credentialsFilePath(), "not json");
94
+ expect(await readStoredCredentials()).toBeUndefined();
95
+ });
96
+ });
97
+
98
+ describe("resolveApiKey", () => {
99
+ it("prefers the explicit option, then env, then the stored login", async () => {
100
+ await writeStoredCredentials({
101
+ backendUrl: cursorBackendUrl(),
102
+ apiKey: "crsr_stored",
103
+ });
104
+ process.env.CURSOR_API_KEY = "crsr_env";
105
+
106
+ expect(await resolveApiKey({ apiKey: "crsr_option" })).toMatchObject({
107
+ apiKey: "crsr_option",
108
+ source: "option",
109
+ });
110
+ expect(await resolveApiKey()).toMatchObject({
111
+ apiKey: "crsr_env",
112
+ source: "env",
113
+ });
114
+ delete process.env.CURSOR_API_KEY;
115
+ expect(await resolveApiKey()).toMatchObject({
116
+ apiKey: "crsr_stored",
117
+ source: "login",
118
+ });
119
+ });
120
+
121
+ it("ignores a stored key minted against a different backend", async () => {
122
+ await writeStoredCredentials({
123
+ backendUrl: "https://localhost:8000",
124
+ apiKey: "crsr_dev_backend",
125
+ });
126
+ expect(await resolveApiKey()).toBeUndefined();
127
+ expect(
128
+ await resolveApiKey({ backendUrl: "https://localhost:8000" })
129
+ ).toMatchObject({ apiKey: "crsr_dev_backend", source: "login" });
130
+ });
131
+
132
+ it("resolves to undefined with no credentials anywhere", async () => {
133
+ expect(await resolveApiKey()).toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe("browser login", () => {
138
+ it("builds a PKCE deep link on the configured website", () => {
139
+ process.env.CURSOR_WEBSITE_URL = "https://localhost:4443";
140
+ const start = startBrowserLogin();
141
+ const url = new URL(start.loginUrl);
142
+ expect(url.origin).toBe("https://localhost:4443");
143
+ expect(url.pathname).toBe("/loginDeepControl");
144
+ expect(url.searchParams.get("uuid")).toBe(start.uuid);
145
+ expect(url.searchParams.get("mode")).toBe("login");
146
+ expect(url.searchParams.get("redirectTarget")).toBe("cli");
147
+ // The challenge is derived from (and never equals) the verifier.
148
+ expect(url.searchParams.get("challenge")).not.toBe(start.verifier);
149
+ expect(url.searchParams.get("challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
150
+ });
151
+
152
+ // The verifier is redeemable for a durable API key, so it must never reach a
153
+ // URL: POST keeps it in the body, out of access logs.
154
+ it("polls POST /auth/poll with the verifier in the body, never the URL", async () => {
155
+ const seen: Array<{ url: string; init?: RequestInit }> = [];
156
+ let calls = 0;
157
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
158
+ seen.push({ url: String(input), init });
159
+ calls++;
160
+ if (calls < 3) {
161
+ return new Response("Not found", { status: 404 });
162
+ }
163
+ return Response.json({ accessToken: "session-token", refreshToken: "r" });
164
+ }) as typeof fetch;
165
+
166
+ const result = await pollForLoginTokens({
167
+ uuid: "uuid-1",
168
+ verifier: "verifier-1",
169
+ backendUrl: "https://backend.example",
170
+ fetchImpl,
171
+ });
172
+ expect(result).toEqual({
173
+ status: "signed_in",
174
+ accessToken: "session-token",
175
+ });
176
+ expect(calls).toBe(3);
177
+ for (const { url, init } of seen) {
178
+ expect(init?.method).toBe("POST");
179
+ // The whole point: no verifier anywhere in the request URL.
180
+ expect(url).toBe("https://backend.example/auth/poll");
181
+ expect(JSON.parse(String(init?.body))).toEqual({
182
+ uuid: "uuid-1",
183
+ verifier: "verifier-1",
184
+ });
185
+ }
186
+ });
187
+
188
+ // Production has not deployed POST /auth/poll yet (the route landed in
189
+ // #167554). Until it does, the login has to work over GET.
190
+ it("falls back to GET, once and stickily, when the backend has no POST route", async () => {
191
+ const seen: Array<{ url: string; method?: string }> = [];
192
+ const warnings: string[] = [];
193
+ let calls = 0;
194
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
195
+ seen.push({ url: String(input), method: init?.method });
196
+ calls++;
197
+ if (calls === 1) {
198
+ return Response.json(
199
+ {
200
+ message: "Route POST:/auth/poll not found",
201
+ error: "Not Found",
202
+ statusCode: 404,
203
+ },
204
+ { status: 404 }
205
+ );
206
+ }
207
+ if (calls === 2) {
208
+ return new Response("Not found", { status: 404 });
209
+ }
210
+ return Response.json({ accessToken: "session-token" });
211
+ }) as typeof fetch;
212
+
213
+ const result = await pollForLoginTokens({
214
+ uuid: "uuid-1",
215
+ verifier: "verifier-1",
216
+ backendUrl: "https://backend.example",
217
+ fetchImpl,
218
+ logger: (line) => warnings.push(line),
219
+ });
220
+ expect(result).toEqual({
221
+ status: "signed_in",
222
+ accessToken: "session-token",
223
+ });
224
+ expect(seen.map((s) => s.method)).toEqual(["POST", "GET", "GET"]);
225
+ // The fallback carries the PKCE material in the query string — the exposure
226
+ // POST exists to avoid — so it must say so out loud, exactly once.
227
+ expect(warnings).toHaveLength(1);
228
+ expect(warnings[0]).toContain("no POST /auth/poll");
229
+ expect(new URL(seen[1]?.url ?? "").searchParams.get("verifier")).toBe(
230
+ "verifier-1"
231
+ );
232
+ });
233
+
234
+ it("fails clearly when the backend serves neither verb", async () => {
235
+ let calls = 0;
236
+ const fetchImpl = (async (
237
+ _input: RequestInfo | URL,
238
+ init?: RequestInit
239
+ ) => {
240
+ calls++;
241
+ return Response.json(
242
+ {
243
+ message: `Route ${init?.method ?? "GET"}:/auth/poll not found`,
244
+ error: "Not Found",
245
+ statusCode: 404,
246
+ },
247
+ { status: 404 }
248
+ );
249
+ }) as typeof fetch;
250
+
251
+ const result = await pollForLoginTokens({
252
+ uuid: "uuid-1",
253
+ verifier: "verifier-1",
254
+ backendUrl: "https://backend.example",
255
+ fetchImpl,
256
+ });
257
+ expect(result).toMatchObject({
258
+ status: "failed",
259
+ message: expect.stringContaining("neither POST nor GET"),
260
+ });
261
+ // POST, then the GET retry. No backoff loop on a route that cannot exist.
262
+ expect(calls).toBe(2);
263
+ });
264
+
265
+ // Fastify's 404 body echoes the full request URL, so on the GET fallback it
266
+ // contains the verifier. Error text is printed to terminals and pasted into
267
+ // bug reports; it must never carry a redeemable secret.
268
+ it("redacts the verifier from error messages", async () => {
269
+ const verifier = "sUpErSeCrEtVeRiFiEr";
270
+ const fetchImpl = (async () =>
271
+ new Response(
272
+ `upstream error for /auth/poll?uuid=u&verifier=${verifier}`,
273
+ { status: 500 }
274
+ )) as typeof fetch;
275
+
276
+ const result = await pollForLoginTokens({
277
+ uuid: "u",
278
+ verifier,
279
+ backendUrl: "https://backend.example",
280
+ fetchImpl,
281
+ });
282
+ expect(result.status).toBe("failed");
283
+ const message = result.status === "failed" ? result.message : "";
284
+ expect(message).not.toContain(verifier);
285
+ expect(message).toContain("[verifier redacted]");
286
+ expect(message).toContain("500");
287
+ });
288
+
289
+ // Regression marker: an earlier poll treated any unrecognized 404 body as
290
+ // fatal, so a body-shape change looked exactly like "you signed in too
291
+ // slowly". On the GET path, an unknown 404 body means "keep waiting".
292
+ it("treats an unrecognized 404 body on the GET fallback as still-pending", async () => {
293
+ let calls = 0;
294
+ const fetchImpl = (async (
295
+ _input: RequestInfo | URL,
296
+ init?: RequestInit
297
+ ) => {
298
+ calls++;
299
+ if (init?.method === "POST") {
300
+ // Force the GET fallback.
301
+ return Response.json(
302
+ { message: "Route POST:/auth/poll not found", statusCode: 404 },
303
+ { status: 404 }
304
+ );
305
+ }
306
+ if (calls < 4) {
307
+ return Response.json({ error: "not_found" }, { status: 404 });
308
+ }
309
+ return Response.json({ accessToken: "session-token" });
310
+ }) as typeof fetch;
311
+
312
+ const result = await pollForLoginTokens({
313
+ uuid: "u",
314
+ verifier: "v",
315
+ backendUrl: "https://backend.example",
316
+ fetchImpl,
317
+ });
318
+ expect(result).toEqual({
319
+ status: "signed_in",
320
+ accessToken: "session-token",
321
+ });
322
+ });
323
+
324
+ it("gives up after repeated hard errors, naming the status", async () => {
325
+ const fetchImpl = (async () =>
326
+ new Response("nope", { status: 500 })) as typeof fetch;
327
+ const result = await pollForLoginTokens({
328
+ uuid: "u",
329
+ verifier: "v",
330
+ backendUrl: "https://backend.example",
331
+ fetchImpl,
332
+ });
333
+ expect(result).toMatchObject({
334
+ status: "failed",
335
+ message: expect.stringContaining("500"),
336
+ });
337
+ });
338
+
339
+ it("times out when the browser sign-in never lands", async () => {
340
+ const fetchImpl = (async () =>
341
+ new Response("Not found", { status: 404 })) as typeof fetch;
342
+ const result = await pollForLoginTokens({
343
+ uuid: "u",
344
+ verifier: "v",
345
+ backendUrl: "https://backend.example",
346
+ fetchImpl,
347
+ maxAttempts: 1,
348
+ });
349
+ expect(result).toEqual({ status: "timed_out" });
350
+ });
351
+ });
@@ -0,0 +1,136 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { parseCursorGithubRepoScope } from "../../channels/github/cursor-account.js";
3
+ import { CursorGithubCredentialProvider } from "./github-credentials.js";
4
+
5
+ describe("CursorGithubCredentialProvider", () => {
6
+ let savedGhToken: string | undefined;
7
+
8
+ beforeEach(() => {
9
+ savedGhToken = process.env.GH_TOKEN;
10
+ process.env.GH_TOKEN = "previous-token";
11
+ });
12
+
13
+ afterEach(() => {
14
+ if (savedGhToken === undefined) {
15
+ delete process.env.GH_TOKEN;
16
+ } else {
17
+ process.env.GH_TOKEN = savedGhToken;
18
+ }
19
+ });
20
+
21
+ it("caches, refreshes, and exports the scoped token for gh", async () => {
22
+ let now = Date.parse("2026-07-16T00:00:00.000Z");
23
+ let mint = 0;
24
+ const getGithubToken = vi.fn(async () => {
25
+ mint++;
26
+ return {
27
+ token: `ghs_scoped_${mint}`,
28
+ expiresAt: new Date(now + 45 * 60_000).toISOString(),
29
+ };
30
+ });
31
+ const provider = new CursorGithubCredentialProvider({
32
+ client: { getGithubToken },
33
+ scope: parseCursorGithubRepoScope(["Acme/Widgets", "acme/widgets"]),
34
+ now: () => now,
35
+ });
36
+
37
+ await provider.start();
38
+ expect(process.env.GH_TOKEN).toBe("ghs_scoped_1");
39
+ await expect(provider.getToken()).resolves.toBe("ghs_scoped_1");
40
+ expect(getGithubToken).toHaveBeenCalledOnce();
41
+ expect(getGithubToken).toHaveBeenCalledWith(["acme/widgets"], {
42
+ permissions: "pr-write",
43
+ });
44
+
45
+ now += 41 * 60_000;
46
+ await expect(provider.getToken()).resolves.toBe("ghs_scoped_2");
47
+ expect(process.env.GH_TOKEN).toBe("ghs_scoped_2");
48
+ expect(getGithubToken).toHaveBeenCalledTimes(2);
49
+
50
+ provider.stop();
51
+ expect(process.env.GH_TOKEN).toBe("previous-token");
52
+ await expect(provider.getToken()).rejects.toThrow(/have been stopped/);
53
+ });
54
+
55
+ it("single-flights concurrent token requests", async () => {
56
+ const expiresAt = new Date(Date.now() + 45 * 60_000).toISOString();
57
+ const getGithubToken = vi.fn(async () => ({
58
+ token: "ghs_one",
59
+ expiresAt,
60
+ }));
61
+ const provider = new CursorGithubCredentialProvider({
62
+ client: { getGithubToken },
63
+ scope: parseCursorGithubRepoScope(["acme/widgets"]),
64
+ });
65
+
66
+ await expect(
67
+ Promise.all([provider.getToken(), provider.getToken()])
68
+ ).resolves.toEqual(["ghs_one", "ghs_one"]);
69
+ expect(getGithubToken).toHaveBeenCalledOnce();
70
+ });
71
+
72
+ it("activates a token fetched before the provider starts", async () => {
73
+ const getGithubToken = vi.fn(async () => ({
74
+ token: "ghs_prefetched",
75
+ expiresAt: new Date(Date.now() + 45 * 60_000).toISOString(),
76
+ }));
77
+ const provider = new CursorGithubCredentialProvider({
78
+ client: { getGithubToken },
79
+ scope: parseCursorGithubRepoScope(["acme/widgets"]),
80
+ });
81
+
82
+ await provider.getToken();
83
+ expect(process.env.GH_TOKEN).toBe("previous-token");
84
+ await provider.start();
85
+ expect(process.env.GH_TOKEN).toBe("ghs_prefetched");
86
+ provider.stop();
87
+ });
88
+
89
+ it("keeps the previous token when an early refresh is transiently unavailable", async () => {
90
+ let now = Date.parse("2026-07-16T00:00:00.000Z");
91
+ const getGithubToken = vi
92
+ .fn()
93
+ .mockResolvedValueOnce({
94
+ token: "ghs_still_valid",
95
+ expiresAt: new Date(now + 45 * 60_000).toISOString(),
96
+ })
97
+ .mockRejectedValueOnce(new Error("backend unavailable"));
98
+ const provider = new CursorGithubCredentialProvider({
99
+ client: { getGithubToken },
100
+ scope: parseCursorGithubRepoScope(["acme/widgets"]),
101
+ now: () => now,
102
+ });
103
+
104
+ await expect(provider.getToken()).resolves.toBe("ghs_still_valid");
105
+ now += 41 * 60_000;
106
+ await expect(provider.getToken()).resolves.toBe("ghs_still_valid");
107
+ });
108
+
109
+ it("evicts an expired lease and negative-caches hard failures", async () => {
110
+ let now = Date.parse("2026-07-16T00:00:00.000Z");
111
+ const getGithubToken = vi
112
+ .fn()
113
+ .mockResolvedValueOnce({
114
+ token: "ghs_expired",
115
+ expiresAt: new Date(now + 45 * 60_000).toISOString(),
116
+ })
117
+ .mockRejectedValue(new Error("GitHub connection missing"));
118
+ const provider = new CursorGithubCredentialProvider({
119
+ client: { getGithubToken },
120
+ scope: parseCursorGithubRepoScope(["acme/widgets"]),
121
+ now: () => now,
122
+ });
123
+
124
+ await expect(provider.getToken()).resolves.toBe("ghs_expired");
125
+ now += 46 * 60_000;
126
+ await expect(provider.getToken()).rejects.toThrow(
127
+ /GitHub connection missing/
128
+ );
129
+ expect(getGithubToken).toHaveBeenCalledTimes(2);
130
+ // Negative cache: do not hot-loop the backend before the backoff elapses.
131
+ await expect(provider.getToken()).rejects.toThrow(
132
+ /GitHub connection missing/
133
+ );
134
+ expect(getGithubToken).toHaveBeenCalledTimes(2);
135
+ });
136
+ });