@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,612 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
7
+ import { ConfigLock } from "../../cloud/configLock";
8
+ import { McpOAuth } from "./McpOAuth";
9
+ import { McpSignIn } from "./McpSignIn";
10
+ import { McpTokenStore } from "./McpTokenStore";
11
+ import { McpToolPack } from "./McpToolPack";
12
+
13
+ // A real server, not stubbed fetches: the wire contract (challenge header, well-known paths, PKCE) is under test.
14
+ class FakeProvider {
15
+ readonly server: ReturnType<typeof Bun.serve>;
16
+ readonly issued: string[] = [];
17
+ readonly registrations: { redirect_uris?: string[] }[] = [];
18
+ readonly sessions: string[] = [];
19
+ readonly calls: { token: string; session: string | null }[] = [];
20
+ readonly #codes = new Map<string, { challenge: string; resource: string | null }>();
21
+ #next = 0;
22
+ methods: string[] = ["S256"];
23
+ registerable = true;
24
+ refreshable = true;
25
+ refreshExpiresIn: number | undefined = 3600;
26
+ refusing = false;
27
+ refreshes = 0;
28
+ refreshDelayMs = 0;
29
+ rotating: boolean = false;
30
+ reused = false;
31
+ readonly #spent = new Set<string>();
32
+
33
+ constructor() {
34
+ this.server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => this.#route(request) });
35
+ }
36
+
37
+ get origin() {
38
+ return `http://127.0.0.1:${this.server.port}`;
39
+ }
40
+
41
+ get mcpUrl() {
42
+ return `${this.origin}/mcp`;
43
+ }
44
+
45
+ close() {
46
+ this.server.stop(true);
47
+ }
48
+
49
+ async #route(request: Request): Promise<Response> {
50
+ const url = new URL(request.url);
51
+ if (url.pathname === "/mcp") return await this.#mcp(request);
52
+ if (url.pathname === "/.well-known/oauth-protected-resource/mcp")
53
+ return Response.json({ resource: this.mcpUrl, authorization_servers: [this.origin], scopes_supported: ["read"] });
54
+ if (url.pathname === "/.well-known/oauth-authorization-server")
55
+ return Response.json({
56
+ issuer: this.origin,
57
+ authorization_endpoint: `${this.origin}/authorize`,
58
+ token_endpoint: `${this.origin}/token`,
59
+ ...(this.registerable ? { registration_endpoint: `${this.origin}/register` } : {}),
60
+ code_challenge_methods_supported: this.methods,
61
+ });
62
+ if (url.pathname === "/register") return await this.#register(request);
63
+ if (url.pathname === "/authorize") return this.#authorize(url);
64
+ if (url.pathname === "/token") return await this.#token(request);
65
+ return new Response("not found", { status: 404 });
66
+ }
67
+
68
+ async #mcp(request: Request) {
69
+ const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
70
+ if (!token || !this.issued.includes(token) || this.refusing)
71
+ return Response.json(
72
+ { error_description: "Authentication is required to use this MCP server." },
73
+ {
74
+ status: 401,
75
+ headers: {
76
+ "WWW-Authenticate": `Bearer resource_metadata="${this.origin}/.well-known/oauth-protected-resource/mcp"`,
77
+ },
78
+ },
79
+ );
80
+ const body = (await request.json()) as { id?: number; method?: string };
81
+ if (body.method === "initialize") {
82
+ const session = `session-${this.sessions.length + 1}`;
83
+ this.sessions.push(session);
84
+ return Response.json(
85
+ { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18" } },
86
+ { headers: { "mcp-session-id": session } },
87
+ );
88
+ }
89
+ if (body.method === "tools/list")
90
+ return Response.json({
91
+ jsonrpc: "2.0",
92
+ id: body.id,
93
+ result: {
94
+ tools: [{ name: "search", description: "search it", inputSchema: { type: "object", properties: {} } }],
95
+ },
96
+ });
97
+ if (body.method === "tools/call") {
98
+ this.calls.push({ token, session: request.headers.get("mcp-session-id") });
99
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "found" }] } });
100
+ }
101
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
102
+ }
103
+
104
+ async #register(request: Request) {
105
+ const body = (await request.json()) as { redirect_uris?: string[] };
106
+ this.registrations.push(body);
107
+ this.#next += 1;
108
+ return Response.json({ client_id: `client-${this.#next}`, redirect_uris: body.redirect_uris });
109
+ }
110
+
111
+ #authorize(url: URL) {
112
+ const redirect = url.searchParams.get("redirect_uri") ?? "";
113
+ const challenge = url.searchParams.get("code_challenge") ?? "";
114
+ const code = `code-${this.#codes.size + 1}`;
115
+ this.#codes.set(code, { challenge, resource: url.searchParams.get("resource") });
116
+ const back = new URL(redirect);
117
+ back.searchParams.set("code", code);
118
+ back.searchParams.set("state", url.searchParams.get("state") ?? "");
119
+ back.searchParams.set("iss", this.origin);
120
+ return Response.redirect(back.href, 302);
121
+ }
122
+
123
+ async #token(request: Request) {
124
+ const form = new URLSearchParams(await request.text());
125
+ if (form.get("grant_type") === "refresh_token") {
126
+ this.refreshes += 1;
127
+ const presented = form.get("refresh_token") ?? "";
128
+ const reuse = this.rotating && this.#spent.has(presented);
129
+ if (this.rotating) this.#spent.add(presented);
130
+ await Bun.sleep(this.refreshDelayMs);
131
+ if (reuse) this.reused = true;
132
+ if (!this.refreshable || reuse) return Response.json({ error: "invalid_grant" }, { status: 400 });
133
+ const access = `access-refreshed-${this.issued.length + 1}`;
134
+ this.issued.push(access);
135
+ const refresh = this.rotating ? `refresh-for-${access}` : "refresh-2";
136
+ return Response.json({ access_token: access, refresh_token: refresh, expires_in: this.refreshExpiresIn });
137
+ }
138
+ const issued = this.#codes.get(form.get("code") ?? "");
139
+ if (!issued) return Response.json({ error: "invalid_grant" }, { status: 400 });
140
+ const verifier = form.get("code_verifier") ?? "";
141
+ if (createHash("sha256").update(verifier).digest("base64url") !== issued.challenge)
142
+ return Response.json({ error: "invalid_grant" }, { status: 400 });
143
+ const access = `access-${this.issued.length + 1}`;
144
+ this.issued.push(access);
145
+ return Response.json({ access_token: access, refresh_token: "refresh-1", expires_in: 3600, scope: "read" });
146
+ }
147
+ }
148
+
149
+ let provider: FakeProvider;
150
+ let home: string;
151
+ let realHome: string | undefined;
152
+
153
+ const refOf = (patch: Partial<CodeAgentMcpServerRef> = {}): CodeAgentMcpServerRef => ({
154
+ name: "fake",
155
+ transport: "http",
156
+ url: provider.mcpUrl,
157
+ ...patch,
158
+ });
159
+
160
+ const browser = (url: string) => void fetch(url).catch(() => undefined);
161
+
162
+ // AKAN_CODE_HOME, not HOME: Bun caches os.homedir(), so a late HOME write would land tokens in the real ~/.akan/code.
163
+ beforeAll(() => {
164
+ realHome = process.env.AKAN_CODE_HOME;
165
+ home = mkdtempSync(path.join(tmpdir(), "akan-mcpauth-"));
166
+ process.env.AKAN_CODE_HOME = home;
167
+ expect(McpTokenStore.file().startsWith(home)).toBe(true);
168
+ });
169
+
170
+ afterAll(() => {
171
+ if (realHome === undefined) delete process.env.AKAN_CODE_HOME;
172
+ else process.env.AKAN_CODE_HOME = realHome;
173
+ rmSync(home, { recursive: true, force: true });
174
+ });
175
+
176
+ beforeEach(() => {
177
+ provider = new FakeProvider();
178
+ rmSync(McpTokenStore.file(), { force: true });
179
+ });
180
+
181
+ afterEach(() => {
182
+ provider.close();
183
+ });
184
+
185
+ describe("MCP OAuth discovery", () => {
186
+ test("the challenge names the metadata, and it is preferred over any url we could derive", () => {
187
+ expect(
188
+ McpOAuth.resourceMetadataUrls("https://x.dev/mcp", 'Bearer resource_metadata="https://x.dev/custom"'),
189
+ ).toEqual(["https://x.dev/custom"]);
190
+ });
191
+
192
+ test("with no challenge, both spellings of the well-known url are tried", () => {
193
+ expect(McpOAuth.resourceMetadataUrls("https://x.dev/mcp")).toEqual([
194
+ "https://x.dev/.well-known/oauth-protected-resource/mcp",
195
+ "https://x.dev/.well-known/oauth-protected-resource",
196
+ ]);
197
+ });
198
+
199
+ test("the issuer's path is inserted after the suffix, as RFC 8414 requires", () => {
200
+ expect(McpOAuth.serverMetadataUrls("https://x.dev/tenant/a")).toEqual([
201
+ "https://x.dev/.well-known/oauth-authorization-server/tenant/a",
202
+ "https://x.dev/tenant/a/.well-known/openid-configuration",
203
+ "https://x.dev/.well-known/oauth-authorization-server",
204
+ ]);
205
+ });
206
+
207
+ test("everything the flow needs is found from the server url alone", async () => {
208
+ const server = await McpOAuth.discover(provider.mcpUrl, await McpSignIn.challenge(refOf()));
209
+ expect(server).toMatchObject({
210
+ issuer: provider.origin,
211
+ authorizationEndpoint: `${provider.origin}/authorize`,
212
+ tokenEndpoint: `${provider.origin}/token`,
213
+ registrationEndpoint: `${provider.origin}/register`,
214
+ resource: provider.mcpUrl,
215
+ resourceScopes: ["read"],
216
+ });
217
+ });
218
+
219
+ test("a server that will not do S256 is refused rather than downgraded", async () => {
220
+ provider.methods = ["plain"];
221
+ await expect(McpOAuth.discover(provider.mcpUrl)).rejects.toThrow(/S256/);
222
+ });
223
+
224
+ test("a server that wants no credential answers no challenge", async () => {
225
+ const open = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => Response.json({ jsonrpc: "2.0", id: 0 }) });
226
+ try {
227
+ expect(await McpSignIn.challenge(refOf({ url: `http://127.0.0.1:${open.port}/mcp` }))).toBeNull();
228
+ } finally {
229
+ open.stop(true);
230
+ }
231
+ });
232
+ });
233
+
234
+ describe("MCP sign-in", () => {
235
+ test("a browser round trip ends with a token this session can connect with", async () => {
236
+ const auth = await McpSignIn.run(refOf(), { open: browser });
237
+ expect(auth.accessToken).toBe("access-1");
238
+ expect(auth.issuer).toBe(provider.origin);
239
+ expect(auth.resource).toBe(provider.mcpUrl);
240
+ expect(provider.registrations).toHaveLength(1);
241
+ expect(provider.registrations[0]?.redirect_uris?.[0]).toBe(auth.redirectUri);
242
+ expect(McpTokenStore.read("fake")?.accessToken).toBe("access-1");
243
+ });
244
+
245
+ test("the token is what makes the server publish its tools", async () => {
246
+ const before = await McpToolPack.connect({
247
+ workspaceRoot: home,
248
+ profile: { name: "t", tools: { mcp: [refOf()] } } as never,
249
+ });
250
+ expect(before?.status[0]).toMatchObject({ auth: "required", tools: [] });
251
+ await McpSignIn.run(refOf(), { open: browser });
252
+ const after = await McpToolPack.connect({
253
+ workspaceRoot: home,
254
+ profile: { name: "t", tools: { mcp: [refOf()] } } as never,
255
+ });
256
+ try {
257
+ expect(after?.status[0]).toMatchObject({ auth: "authorized" });
258
+ expect(after?.toolNames).toEqual(["mcp__fake__search"]);
259
+ } finally {
260
+ after?.close();
261
+ before?.close();
262
+ }
263
+ });
264
+
265
+ test("a second sign-in reuses the client it already registered", async () => {
266
+ await McpSignIn.run(refOf(), { open: browser });
267
+ await McpSignIn.run(refOf(), { open: browser });
268
+ expect(provider.registrations).toHaveLength(1);
269
+ });
270
+
271
+ test("an expired token is refreshed without anybody being asked", async () => {
272
+ const auth = await McpSignIn.run(refOf(), { open: browser });
273
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
274
+ expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
275
+ expect(McpTokenStore.read("fake")?.refreshToken).toBe("refresh-2");
276
+ });
277
+
278
+ test("a refreshed token the server gave no lifetime is kept until refused, not refreshed on every connect", async () => {
279
+ const auth = await McpSignIn.run(refOf(), { open: browser });
280
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
281
+ provider.refreshExpiresIn = undefined;
282
+ expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
283
+ expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
284
+ expect(McpTokenStore.read("fake")?.expiresAt).toBeUndefined();
285
+ });
286
+
287
+ test("a refresh the server refuses reports no token rather than throwing", async () => {
288
+ const auth = await McpSignIn.run(refOf(), { open: browser });
289
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
290
+ provider.refreshable = false;
291
+ const notices: string[] = [];
292
+ expect(await McpSignIn.token(refOf(), (message) => notices.push(message))).toBeUndefined();
293
+ expect(notices.join(" ")).toContain("fake");
294
+ });
295
+
296
+ const connect = async (notices: string[] = []) =>
297
+ await McpToolPack.connect({
298
+ workspaceRoot: home,
299
+ profile: { name: "t", tools: { mcp: [refOf()] } } as never,
300
+ onNotice: (message) => notices.push(message),
301
+ });
302
+
303
+ test("a token refused before its expiry is refreshed once, and the connect goes through with the new one", async () => {
304
+ const auth = await McpSignIn.run(refOf(), { open: browser });
305
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
306
+ const notices: string[] = [];
307
+ const pack = await connect(notices);
308
+ try {
309
+ expect(pack?.status[0]).toMatchObject({ auth: "authorized" });
310
+ expect(pack?.toolNames).toEqual(["mcp__fake__search"]);
311
+ expect(McpTokenStore.read("fake")).toMatchObject({
312
+ accessToken: "access-refreshed-1",
313
+ refreshToken: "refresh-2",
314
+ });
315
+ expect(notices).toEqual([]);
316
+ } finally {
317
+ pack?.close();
318
+ }
319
+ });
320
+
321
+ test("a refused token whose refresh fails is a sign-in, not a loop", async () => {
322
+ await McpSignIn.run(refOf(), { open: browser });
323
+ provider.issued.splice(0);
324
+ provider.refreshable = false;
325
+ const notices: string[] = [];
326
+ const pack = await connect(notices);
327
+ try {
328
+ expect(pack?.status[0]).toMatchObject({ auth: "required", tools: [] });
329
+ expect(notices.at(-1)).toContain("/mcp login fake");
330
+ } finally {
331
+ pack?.close();
332
+ }
333
+ });
334
+
335
+ test("a refreshed token that is refused too is not refreshed again", async () => {
336
+ await McpSignIn.run(refOf(), { open: browser });
337
+ provider.refusing = true;
338
+ const pack = await connect();
339
+ try {
340
+ expect(pack?.status[0]).toMatchObject({ auth: "required", tools: [] });
341
+ expect(provider.issued).toEqual(["access-1", "access-refreshed-2"]);
342
+ } finally {
343
+ pack?.close();
344
+ }
345
+ });
346
+
347
+ type McpExecute = (id: string, params: Record<string, unknown>) => Promise<{ content: { text: string }[] }>;
348
+ const search = async (pack: McpToolPack | undefined) => {
349
+ const tools = new Map<string, McpExecute>();
350
+ const extension = pack?.extension();
351
+ if (extension && "factory" in extension)
352
+ extension.factory({
353
+ registerTool: (tool: { name: string; execute: McpExecute }) => tools.set(tool.name, tool.execute),
354
+ } as never);
355
+ const execute = tools.get("mcp__fake__search");
356
+ if (!execute) throw new Error("the pack published no search tool");
357
+ return await execute("call", {});
358
+ };
359
+
360
+ test("a token refused mid-session is refreshed once, and the call is retried on a new session with the new token", async () => {
361
+ const auth = await McpSignIn.run(refOf(), { open: browser });
362
+ const pack = await connect();
363
+ try {
364
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
365
+ expect((await search(pack)).content[0]?.text).toBe("found");
366
+ expect(provider.refreshes).toBe(1);
367
+ expect(provider.calls).toEqual([{ token: "access-refreshed-1", session: "session-2" }]);
368
+ expect(McpTokenStore.read("fake")).toMatchObject({
369
+ accessToken: "access-refreshed-1",
370
+ refreshToken: "refresh-2",
371
+ });
372
+ } finally {
373
+ pack?.close();
374
+ }
375
+ });
376
+
377
+ test("calls refused together share one refresh and one new session", async () => {
378
+ const auth = await McpSignIn.run(refOf(), { open: browser });
379
+ const pack = await connect();
380
+ try {
381
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
382
+ const answers = await Promise.all([search(pack), search(pack), search(pack)]);
383
+ expect(answers.map((answer) => answer.content[0]?.text)).toEqual(["found", "found", "found"]);
384
+ expect(provider.refreshes).toBe(1);
385
+ expect(provider.sessions).toEqual(["session-1", "session-2"]);
386
+ } finally {
387
+ pack?.close();
388
+ }
389
+ });
390
+
391
+ test("a sub-agent's pack refused at the same moment waits on the same refresh", async () => {
392
+ const auth = await McpSignIn.run(refOf(), { open: browser });
393
+ const [parent, child] = [await connect(), await connect()];
394
+ try {
395
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
396
+ provider.refreshDelayMs = 100;
397
+ const answers = await Promise.all([search(parent), search(child)]);
398
+ expect(answers.map((answer) => answer.content[0]?.text)).toEqual(["found", "found"]);
399
+ expect(provider.refreshes).toBe(1);
400
+ } finally {
401
+ parent?.close();
402
+ child?.close();
403
+ }
404
+ });
405
+
406
+ test("a session connecting with an expired token while a call is refused shares one refresh with it", async () => {
407
+ const auth = await McpSignIn.run(refOf(), { open: browser });
408
+ McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
409
+ provider.refreshDelayMs = 50;
410
+ const tokens = await Promise.all([McpSignIn.token(refOf()), McpSignIn.retryToken(refOf(), auth.accessToken)]);
411
+ expect(tokens).toEqual(["access-refreshed-2", "access-refreshed-2"]);
412
+ expect(provider.refreshes).toBe(1);
413
+ });
414
+
415
+ test("a mid-session refusal whose refresh fails names /mcp login, and the next call does not refresh again", async () => {
416
+ await McpSignIn.run(refOf(), { open: browser });
417
+ const notices: string[] = [];
418
+ const pack = await connect(notices);
419
+ try {
420
+ provider.issued.splice(0);
421
+ provider.refreshable = false;
422
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
423
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
424
+ expect(provider.refreshes).toBe(1);
425
+ expect(notices.join(" ")).toContain("needs signing in again");
426
+ } finally {
427
+ pack?.close();
428
+ }
429
+ });
430
+
431
+ test("a server whose token cannot be renewed mid-session reads as a failed connect does, until a sign-in and a reload", async () => {
432
+ await McpSignIn.run(refOf(), { open: browser });
433
+ const pack = await connect();
434
+ try {
435
+ expect(pack?.status[0]).toMatchObject({ auth: "authorized" });
436
+ provider.issued.splice(0);
437
+ provider.refreshable = false;
438
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
439
+ expect(pack?.status[0]).toMatchObject({ auth: "required", tools: ["mcp__fake__search"] });
440
+ } finally {
441
+ pack?.close();
442
+ }
443
+ provider.refreshable = true;
444
+ await McpSignIn.run(refOf(), { open: browser });
445
+ const reloaded = await connect();
446
+ try {
447
+ expect(reloaded?.status[0]).toMatchObject({ auth: "authorized" });
448
+ expect((await search(reloaded)).content[0]?.text).toBe("found");
449
+ } finally {
450
+ reloaded?.close();
451
+ }
452
+ });
453
+
454
+ test("a server that refuses the renewed token too reads as needing a sign-in", async () => {
455
+ await McpSignIn.run(refOf(), { open: browser });
456
+ const pack = await connect();
457
+ try {
458
+ provider.refusing = true;
459
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
460
+ expect(pack?.status[0]).toMatchObject({ auth: "required" });
461
+ } finally {
462
+ pack?.close();
463
+ }
464
+ });
465
+
466
+ test("a refreshed token the server refuses too ends the call in /mcp login after one refresh", async () => {
467
+ await McpSignIn.run(refOf(), { open: browser });
468
+ const pack = await connect();
469
+ try {
470
+ provider.refusing = true;
471
+ await expect(search(pack)).rejects.toThrow("/mcp login fake");
472
+ expect(provider.refreshes).toBe(1);
473
+ expect(provider.issued).toEqual(["access-1", "access-refreshed-2"]);
474
+ } finally {
475
+ pack?.close();
476
+ }
477
+ });
478
+
479
+ test("a provider that registers no clients says what to declare instead", async () => {
480
+ provider.registerable = false;
481
+ await expect(McpSignIn.run(refOf(), { open: browser })).rejects.toThrow(/clientId/);
482
+ });
483
+
484
+ test("a declared client id is used instead of registering one", async () => {
485
+ const auth = await McpSignIn.run(refOf({ oauth: { clientId: "mine" } }), { open: browser });
486
+ expect(auth.clientId).toBe("mine");
487
+ expect(provider.registrations).toHaveLength(0);
488
+ });
489
+
490
+ test("a stdio server is told its credential goes in env", async () => {
491
+ await expect(McpSignIn.run({ name: "s", transport: "stdio", command: "x" }, { open: browser })).rejects.toThrow(
492
+ /env/,
493
+ );
494
+ });
495
+
496
+ test("signing out forgets the token and leaves nothing behind", async () => {
497
+ await McpSignIn.run(refOf(), { open: browser });
498
+ expect(McpTokenStore.clear("fake")).toBe(true);
499
+ expect(McpTokenStore.read("fake")).toBeUndefined();
500
+ expect(McpTokenStore.clear("fake")).toBe(false);
501
+ });
502
+ });
503
+
504
+ describe("the token file", () => {
505
+ test("it lives outside the workspace", async () => {
506
+ await McpSignIn.run(refOf(), { open: browser });
507
+ const file = McpTokenStore.file();
508
+ expect(file.startsWith(home)).toBe(true);
509
+ expect(await Bun.file(file).exists()).toBe(true);
510
+ });
511
+
512
+ test.skipIf(process.platform === "win32")("it is readable only by its owner", async () => {
513
+ await McpSignIn.run(refOf(), { open: browser });
514
+ expect(statSync(McpTokenStore.file()).mode & 0o777).toBe(0o600);
515
+ });
516
+
517
+ test("a corrupt file costs the sign-ins and not the session", async () => {
518
+ await McpSignIn.run(refOf(), { open: browser });
519
+ await Bun.write(McpTokenStore.file(), "{ not json");
520
+ expect(McpTokenStore.read("fake")).toBeUndefined();
521
+ expect(McpTokenStore.names()).toEqual([]);
522
+ });
523
+ });
524
+
525
+ describe("two akan code processes", () => {
526
+ const lines = (stream: ReadableStream<Uint8Array>) => {
527
+ const reader = stream.getReader();
528
+ const decoder = new TextDecoder();
529
+ let buffer = "";
530
+ return async () => {
531
+ while (!buffer.includes("\n")) {
532
+ const chunk = await reader.read();
533
+ if (chunk.done) return buffer;
534
+ buffer += decoder.decode(chunk.value, { stream: true });
535
+ }
536
+ const [line = "", ...rest] = buffer.split("\n");
537
+ buffer = rest.join("\n");
538
+ return line;
539
+ };
540
+ };
541
+
542
+ // A separate `bun` per session, pointed at this file's AKAN_CODE_HOME so both read and write the one token file.
543
+ const session = async () => {
544
+ const script = path.join(home, `session-${crypto.randomUUID()}.ts`);
545
+ await Bun.write(
546
+ script,
547
+ `import { McpToolPack } from ${JSON.stringify(path.resolve(import.meta.dir, "McpToolPack.ts"))};
548
+ import { McpTokenStore } from ${JSON.stringify(path.resolve(import.meta.dir, "McpTokenStore.ts"))};
549
+ if (!McpTokenStore.file().startsWith(${JSON.stringify(home)})) throw new Error("refusing to touch " + McpTokenStore.file());
550
+ const pack = await McpToolPack.connect({
551
+ workspaceRoot: ${JSON.stringify(home)},
552
+ profile: { name: "t", tools: { mcp: [${JSON.stringify(refOf())}] } },
553
+ });
554
+ let execute;
555
+ const extension = pack?.extension();
556
+ if (extension && "factory" in extension)
557
+ extension.factory({ registerTool: (tool) => { if (tool.name === "mcp__fake__search") execute = tool.execute; } });
558
+ process.stdout.write("ready\\n");
559
+ for await (const line of console) if (line.trim() === "go") break;
560
+ const answer = await execute("call", {}).then((result) => result.content[0].text, (error) => "error: " + String(error));
561
+ process.stdout.write(JSON.stringify(answer) + "\\n");
562
+ pack?.close();
563
+ process.exit(0);
564
+ `,
565
+ );
566
+ const proc = Bun.spawn(["bun", script], {
567
+ env: { ...process.env, AKAN_CODE_HOME: home },
568
+ stdin: "pipe",
569
+ stdout: "pipe",
570
+ stderr: "pipe",
571
+ });
572
+ return { proc, line: lines(proc.stdout) };
573
+ };
574
+
575
+ test("a refresh waits for the lock another session holds, then takes the token that session stored", async () => {
576
+ const auth = await McpSignIn.run(refOf(), { open: browser });
577
+ const taken = Promise.withResolvers<void>();
578
+ const released = Promise.withResolvers<void>();
579
+ const elsewhere = ConfigLock.run(McpTokenStore.lockFile(), async () => {
580
+ taken.resolve();
581
+ await released.promise;
582
+ });
583
+ await taken.promise;
584
+ const retried = McpSignIn.retryToken(refOf(), auth.accessToken);
585
+ McpTokenStore.write("fake", { ...auth, accessToken: "access-elsewhere", refreshToken: "refresh-elsewhere" });
586
+ released.resolve();
587
+ await elsewhere;
588
+ expect(await retried).toBe("access-elsewhere");
589
+ expect(provider.refreshes).toBe(0);
590
+ });
591
+
592
+ test("refused at the same moment, they make one refresh between them, and both calls go through", async () => {
593
+ const auth = await McpSignIn.run(refOf(), { open: browser });
594
+ provider.rotating = true;
595
+ provider.refreshDelayMs = 300;
596
+ const sessions = [await session(), await session()];
597
+ try {
598
+ expect(await Promise.all(sessions.map(({ line }) => line()))).toEqual(["ready", "ready"]);
599
+ provider.issued.splice(provider.issued.indexOf(auth.accessToken), 1);
600
+ for (const { proc } of sessions) {
601
+ proc.stdin.write("go\n");
602
+ await proc.stdin.flush();
603
+ }
604
+ const answers = await Promise.all(sessions.map(({ line }) => line()));
605
+ expect(answers).toEqual(['"found"', '"found"']);
606
+ expect(provider.refreshes).toBe(1);
607
+ expect(provider.reused).toBe(false);
608
+ } finally {
609
+ for (const { proc } of sessions) proc.kill();
610
+ }
611
+ }, 30_000);
612
+ });