@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.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 (188) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -1,11 +1,13 @@
1
- import { describe, expect, spyOn, test } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
2
4
  import path from "node:path";
3
5
  import { codeAgentPresets } from "akanjs/common";
4
6
  import { makeCliTempWorkspace, tempRoots, writeText } from "../../testHelpers";
5
7
  import { CodeAgent, type CodeAgentOptions } from "../agent/CodeAgent";
6
8
  import { SubagentPool } from "../agent/SubagentPool";
9
+ import { WorkspaceTrust } from "../agent/WorkspaceTrust";
7
10
  import { DevLogFeedback } from "../feedback/DevLogFeedback";
8
- import { PreviewView } from "../feedback/PreviewView";
9
11
  import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
10
12
  import { AkanCodePlugins } from "./AkanCodePlugins";
11
13
  import { AkanEditScope } from "./AkanEditScope";
@@ -20,6 +22,26 @@ import { WebToolPack } from "./WebToolPack";
20
22
  const track = tempRoots();
21
23
  const tempRoot = async () => track(await makeCliTempWorkspace()).root;
22
24
 
25
+ let codeHome: string;
26
+ let realCodeHome: string | undefined;
27
+
28
+ beforeAll(() => {
29
+ realCodeHome = process.env.AKAN_CODE_HOME;
30
+ codeHome = mkdtempSync(path.join(tmpdir(), "akan-code-home-"));
31
+ process.env.AKAN_CODE_HOME = codeHome;
32
+ });
33
+
34
+ afterAll(() => {
35
+ if (realCodeHome === undefined) delete process.env.AKAN_CODE_HOME;
36
+ else process.env.AKAN_CODE_HOME = realCodeHome;
37
+ rmSync(codeHome, { recursive: true, force: true });
38
+ });
39
+
40
+ const approved = (root: string) => {
41
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), false);
42
+ return root;
43
+ };
44
+
23
45
  type ToolExecute = (
24
46
  id: string,
25
47
  params: never,
@@ -111,38 +133,18 @@ describe("TurnFeedback", () => {
111
133
  });
112
134
 
113
135
  describe("AkanVerifier.summarize", () => {
114
- const scope = { paths: ["apps/a/x.ts"], apps: ["a"], libs: [], needsSync: false, touchesTsx: false };
136
+ test("passes the verify report through untouched", () => {
137
+ const output =
138
+ "verify fail · a · lint ok · typecheck 1 error · 4.1s\napps/a/x.ts:1:7 TS2322 Type 'string' is not assignable to type 'number'.";
139
+ expect(AkanVerifier.summarize({ command: "akan verify", ok: false, exitCode: 1, output })).toBe(output);
140
+ });
115
141
 
116
- test("names the failing step and keeps only its tail", () => {
142
+ test("keeps only the tail of a crash that never reached the report", () => {
117
143
  const output = [...Array(100).keys()].map((n) => `line ${n}`).join("\n");
118
- const text = AkanVerifier.summarize(
119
- {
120
- ok: false,
121
- scope,
122
- steps: [
123
- { command: "akan lint a", ok: true, exitCode: 0, output: "" },
124
- { command: "akan typecheck a", ok: false, exitCode: 1, output },
125
- ],
126
- },
127
- { tailLines: 5 },
128
- );
129
- expect(text).toContain("Verification failed: akan typecheck a");
130
- expect(text).toContain("Passed: akan lint a");
144
+ const text = AkanVerifier.summarize({ command: "akan verify", ok: false, exitCode: 1, output });
145
+ expect(text).toStartWith("akan verify exited 1 before reporting:");
131
146
  expect(text).toContain("line 99");
132
- expect(text).not.toContain("line 10\n");
133
- });
134
-
135
- test("says so plainly when everything passed", () => {
136
- const text = AkanVerifier.summarize({
137
- ok: true,
138
- scope,
139
- steps: [{ command: "akan lint a", ok: true, exitCode: 0, output: "" }],
140
- });
141
- expect(text).toContain("Verification passed");
142
- });
143
-
144
- test("an untouched workspace is not a failure", () => {
145
- expect(AkanVerifier.summarize({ ok: true, scope, steps: [] })).toContain("nothing to verify");
147
+ expect(text).not.toContain("line 79\n");
146
148
  });
147
149
  });
148
150
 
@@ -153,9 +155,11 @@ describe("AkanEditScope", () => {
153
155
  const baseline = await AkanEditScope.baseline(root);
154
156
  await writeText(path.join(root, "apps", "demo", "lib", "post", "post.service.ts"), "export {};\n");
155
157
  await writeText(path.join(root, "libs", "shared", "ui", "Card.tsx"), "export const Card = () => null;\n");
158
+ await writeText(path.join(root, "pkgs", "@akanjs", "cli", "index.ts"), "export {};\n");
156
159
  const scope = await AkanEditScope.since(root, baseline);
157
160
  expect(scope.apps).toEqual(["demo"]);
158
161
  expect(scope.libs).toEqual(["shared"]);
162
+ expect(scope.pkgs).toEqual(["@akanjs/cli"]);
159
163
  expect(scope.needsSync).toBe(true);
160
164
  expect(scope.touchesTsx).toBe(true);
161
165
  });
@@ -165,7 +169,7 @@ describe("AkanEditScope", () => {
165
169
  await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
166
170
  const baseline = await AkanEditScope.baseline(root);
167
171
  const scope = await AkanEditScope.since(root, baseline);
168
- expect(scope).toEqual({ paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false });
172
+ expect(scope).toEqual({ paths: [], apps: [], libs: [], pkgs: [], needsSync: false, touchesTsx: false });
169
173
  });
170
174
  });
171
175
 
@@ -243,33 +247,6 @@ describe("AkanEnvKeys", () => {
243
247
  });
244
248
  });
245
249
 
246
- describe("PreviewView", () => {
247
- test("reports whether this Bun build can drive a headless page at all", () => {
248
- expect(typeof PreviewView.available).toBe("boolean");
249
- });
250
-
251
- test.skipIf(!PreviewView.available)(
252
- "finds an empty body and a console error on a real page",
253
- async () => {
254
- const server = Bun.serve({
255
- port: 0,
256
- fetch: () =>
257
- new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
258
- headers: { "content-type": "text/html" },
259
- }),
260
- });
261
- try {
262
- const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
263
- expect(probe.pageErrors.join(" ")).toContain("rendered empty");
264
- expect(probe.consoleErrors.join(" ")).toContain("boom");
265
- } finally {
266
- server.stop(true);
267
- }
268
- },
269
- 30_000,
270
- );
271
- });
272
-
273
250
  describe("SubagentPool", () => {
274
251
  const workspace = { workspaceRoot: "/tmp/akan" } as never;
275
252
 
@@ -519,6 +496,54 @@ describe("SubagentPool", () => {
519
496
  }
520
497
  });
521
498
 
499
+ test("a child runs on the model its parent runs on when the task starts", async () => {
500
+ const { opened, spy } = openFakeAgents([]);
501
+ let current = { provider: "deepseek", id: "deepseek-v4-pro" };
502
+ try {
503
+ const root = SubagentPool.extensionFor({
504
+ workspace,
505
+ cwd: "/tmp/akan",
506
+ parent: budgeted(1_000),
507
+ model: () => current,
508
+ depth: 0,
509
+ });
510
+ await runTask(root, "c1");
511
+ current = { provider: "openai", id: "gpt-5.5" };
512
+ await runTask(root, "c2");
513
+ expect(opened.map((options) => options.model)).toEqual([
514
+ { provider: "deepseek", id: "deepseek-v4-pro" },
515
+ { provider: "openai", id: "gpt-5.5" },
516
+ ]);
517
+ } finally {
518
+ spy.mockRestore();
519
+ }
520
+ });
521
+
522
+ test("the plugins hand their pool the owning agent's model", async () => {
523
+ const { opened, spy } = openFakeAgents([]);
524
+ try {
525
+ const plugins = await AkanCodePlugins.build({
526
+ workspace,
527
+ cwd: "/tmp/akan",
528
+ profile: budgeted(1_000),
529
+ apps: [],
530
+ canSeeImages: false,
531
+ model: () => ({ provider: "deepseek", id: "deepseek-v4-pro" }),
532
+ depth: 0,
533
+ currentSessionId: () => "",
534
+ mailbox: () => undefined,
535
+ });
536
+ await runTask(
537
+ plugins.extensions.find((extension) => extension.name === "akan-subagent"),
538
+ "c1",
539
+ );
540
+ expect(opened[0]?.model).toEqual({ provider: "deepseek", id: "deepseek-v4-pro" });
541
+ plugins.dispose();
542
+ } finally {
543
+ spy.mockRestore();
544
+ }
545
+ });
546
+
522
547
  test("a child keeps no session and cannot ask a human", () => {
523
548
  const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
524
549
  expect(child.session).toEqual({ store: "memory", crossSession: false });
@@ -552,8 +577,20 @@ for await (const line of console) {
552
577
  return root;
553
578
  };
554
579
 
555
- test("connects a declared stdio server, lists its tools and calls one", async () => {
580
+ test("a server the repo declares is not started until it is approved", async () => {
556
581
  const root = await withServer();
582
+ const notices: string[] = [];
583
+ const pack = await McpToolPack.connect({
584
+ workspaceRoot: root,
585
+ profile: codeAgentPresets.local(root),
586
+ onNotice: (message) => notices.push(message),
587
+ });
588
+ expect(pack).toBeUndefined();
589
+ expect(notices.join(" ")).toContain("not approved: echo");
590
+ });
591
+
592
+ test("connects a declared stdio server, lists its tools and calls one", async () => {
593
+ const root = approved(await withServer());
557
594
  const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.local(root) });
558
595
  expect(pack).toBeDefined();
559
596
  try {
@@ -580,6 +617,7 @@ for await (const line of console) {
580
617
  path.join(root, ".akan", "code", "mcp.json"),
581
618
  JSON.stringify({ mcpServers: { broken: { command: "/nonexistent/binary" } } }),
582
619
  );
620
+ approved(root);
583
621
  const notices: string[] = [];
584
622
  const pack = await McpToolPack.connect({
585
623
  workspaceRoot: root,
@@ -654,6 +692,38 @@ describe("WebToolPack", () => {
654
692
  }
655
693
  });
656
694
 
695
+ test("follows a redirect only to a host the profile reaches without asking", async () => {
696
+ const server: ReturnType<typeof Bun.serve> = Bun.serve({
697
+ port: 0,
698
+ fetch: (request): Response => {
699
+ const { pathname } = new URL(request.url);
700
+ if (pathname === "/out") return Response.redirect("https://outside.example/page", 302);
701
+ if (pathname === "/in") return Response.redirect(`http://localhost:${server.port}/final`, 302);
702
+ if (pathname === "/loopback") return Response.redirect(`http://127.0.0.1:${server.port}/final`, 302);
703
+ return new Response("<p>final page</p>");
704
+ },
705
+ });
706
+ try {
707
+ const profile = codeAgentPresets.local(root);
708
+ const read = async (pack: WebToolPack, path: string) =>
709
+ await toolsOf(pack.extension()).get("web_fetch")?.("1", {
710
+ url: `http://localhost:${server.port}${path}`,
711
+ } as never);
712
+
713
+ const local = new WebToolPack({ profile });
714
+ expect((await read(local, "/in"))?.content[0]?.text).toContain("final page");
715
+ const out = await read(local, "/out");
716
+ expect(out?.isError).toBe(true);
717
+ expect(out?.content[0]?.text).toContain("Redirected to https://outside.example/page");
718
+
719
+ const narrowed = new WebToolPack({ profile: { ...profile, network: { allowHosts: ["localhost"] } } });
720
+ expect((await read(narrowed, "/in"))?.content[0]?.text).toContain("final page");
721
+ expect((await read(narrowed, "/loopback"))?.content[0]?.text).toContain(`Redirected to http://127.0.0.1:`);
722
+ } finally {
723
+ server.stop(true);
724
+ }
725
+ });
726
+
657
727
  test("reads a long page a window at a time, continuing from the snapshot it first read", async () => {
658
728
  let hits = 0;
659
729
  const server = Bun.serve({
@@ -239,7 +239,7 @@ describe("MCP sign-in", () => {
239
239
  expect(auth.resource).toBe(provider.mcpUrl);
240
240
  expect(provider.registrations).toHaveLength(1);
241
241
  expect(provider.registrations[0]?.redirect_uris?.[0]).toBe(auth.redirectUri);
242
- expect(McpTokenStore.read("fake")?.accessToken).toBe("access-1");
242
+ expect(McpTokenStore.read(refOf())?.accessToken).toBe("access-1");
243
243
  });
244
244
 
245
245
  test("the token is what makes the server publish its tools", async () => {
@@ -270,23 +270,23 @@ describe("MCP sign-in", () => {
270
270
 
271
271
  test("an expired token is refreshed without anybody being asked", async () => {
272
272
  const auth = await McpSignIn.run(refOf(), { open: browser });
273
- McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
273
+ McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
274
274
  expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
275
- expect(McpTokenStore.read("fake")?.refreshToken).toBe("refresh-2");
275
+ expect(McpTokenStore.read(refOf())?.refreshToken).toBe("refresh-2");
276
276
  });
277
277
 
278
278
  test("a refreshed token the server gave no lifetime is kept until refused, not refreshed on every connect", async () => {
279
279
  const auth = await McpSignIn.run(refOf(), { open: browser });
280
- McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
280
+ McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
281
281
  provider.refreshExpiresIn = undefined;
282
282
  expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
283
283
  expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
284
- expect(McpTokenStore.read("fake")?.expiresAt).toBeUndefined();
284
+ expect(McpTokenStore.read(refOf())?.expiresAt).toBeUndefined();
285
285
  });
286
286
 
287
287
  test("a refresh the server refuses reports no token rather than throwing", async () => {
288
288
  const auth = await McpSignIn.run(refOf(), { open: browser });
289
- McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
289
+ McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
290
290
  provider.refreshable = false;
291
291
  const notices: string[] = [];
292
292
  expect(await McpSignIn.token(refOf(), (message) => notices.push(message))).toBeUndefined();
@@ -308,7 +308,7 @@ describe("MCP sign-in", () => {
308
308
  try {
309
309
  expect(pack?.status[0]).toMatchObject({ auth: "authorized" });
310
310
  expect(pack?.toolNames).toEqual(["mcp__fake__search"]);
311
- expect(McpTokenStore.read("fake")).toMatchObject({
311
+ expect(McpTokenStore.read(refOf())).toMatchObject({
312
312
  accessToken: "access-refreshed-1",
313
313
  refreshToken: "refresh-2",
314
314
  });
@@ -365,7 +365,7 @@ describe("MCP sign-in", () => {
365
365
  expect((await search(pack)).content[0]?.text).toBe("found");
366
366
  expect(provider.refreshes).toBe(1);
367
367
  expect(provider.calls).toEqual([{ token: "access-refreshed-1", session: "session-2" }]);
368
- expect(McpTokenStore.read("fake")).toMatchObject({
368
+ expect(McpTokenStore.read(refOf())).toMatchObject({
369
369
  accessToken: "access-refreshed-1",
370
370
  refreshToken: "refresh-2",
371
371
  });
@@ -405,7 +405,7 @@ describe("MCP sign-in", () => {
405
405
 
406
406
  test("a session connecting with an expired token while a call is refused shares one refresh with it", async () => {
407
407
  const auth = await McpSignIn.run(refOf(), { open: browser });
408
- McpTokenStore.write("fake", { ...auth, expiresAt: Date.now() - 1 });
408
+ McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
409
409
  provider.refreshDelayMs = 50;
410
410
  const tokens = await Promise.all([McpSignIn.token(refOf()), McpSignIn.retryToken(refOf(), auth.accessToken)]);
411
411
  expect(tokens).toEqual(["access-refreshed-2", "access-refreshed-2"]);
@@ -495,9 +495,23 @@ describe("MCP sign-in", () => {
495
495
 
496
496
  test("signing out forgets the token and leaves nothing behind", async () => {
497
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);
498
+ expect(McpTokenStore.clear(refOf())).toBe(true);
499
+ expect(McpTokenStore.read(refOf())).toBeUndefined();
500
+ expect(McpTokenStore.clear(refOf())).toBe(false);
501
+ });
502
+
503
+ test("signing out of a renamed server forgets the token its origin still hands it", async () => {
504
+ await McpSignIn.run(refOf(), { open: browser });
505
+ expect(McpTokenStore.read(refOf({ name: "renamed" }))?.accessToken).toBe("access-1");
506
+ expect(McpTokenStore.clear(refOf({ name: "renamed" }))).toBe(true);
507
+ expect(McpTokenStore.read(refOf({ name: "renamed" }))).toBeUndefined();
508
+ expect(McpTokenStore.names()).toEqual([]);
509
+ });
510
+
511
+ test("signing out by the name alone forgets a token whose server is no longer declared", async () => {
512
+ await McpSignIn.run(refOf(), { open: browser });
513
+ expect(McpTokenStore.clear({ name: "fake" })).toBe(true);
514
+ expect(McpTokenStore.read(refOf())).toBeUndefined();
501
515
  });
502
516
  });
503
517
 
@@ -514,10 +528,32 @@ describe("the token file", () => {
514
528
  expect(statSync(McpTokenStore.file()).mode & 0o777).toBe(0o600);
515
529
  });
516
530
 
531
+ test("a token is found by the server's origin, so the same name at another url gets none", async () => {
532
+ await McpSignIn.run(refOf(), { open: browser });
533
+ expect(await McpSignIn.token(refOf({ url: "http://127.0.0.1:9/mcp" }))).toBeUndefined();
534
+ expect(await McpSignIn.token(refOf({ name: "renamed" }))).toBe("access-1");
535
+ expect(McpTokenStore.names()).toEqual(["fake"]);
536
+ });
537
+
538
+ test("a file an older build keyed by name keeps its tokens at the origin they were minted for", async () => {
539
+ const auth = await McpSignIn.run(refOf(), { open: browser });
540
+ await Bun.write(
541
+ McpTokenStore.file(),
542
+ JSON.stringify({ servers: { fake: auth, broken: { ...auth, resource: "" } } }),
543
+ );
544
+ expect(McpTokenStore.read(refOf())?.accessToken).toBe("access-1");
545
+ expect(McpTokenStore.read(refOf({ url: "https://elsewhere.example/mcp" }))).toBeUndefined();
546
+ expect(McpTokenStore.names()).toEqual(["fake"]);
547
+ McpTokenStore.write(refOf(), { ...auth, accessToken: "access-rewritten" });
548
+ const saved = (await Bun.file(McpTokenStore.file()).json()) as { servers?: unknown; tokens: object };
549
+ expect(saved.servers).toBeUndefined();
550
+ expect(Object.keys(saved.tokens)).toEqual([`${provider.origin} ${provider.origin}`]);
551
+ });
552
+
517
553
  test("a corrupt file costs the sign-ins and not the session", async () => {
518
554
  await McpSignIn.run(refOf(), { open: browser });
519
555
  await Bun.write(McpTokenStore.file(), "{ not json");
520
- expect(McpTokenStore.read("fake")).toBeUndefined();
556
+ expect(McpTokenStore.read(refOf())).toBeUndefined();
521
557
  expect(McpTokenStore.names()).toEqual([]);
522
558
  });
523
559
  });
@@ -582,7 +618,7 @@ process.exit(0);
582
618
  });
583
619
  await taken.promise;
584
620
  const retried = McpSignIn.retryToken(refOf(), auth.accessToken);
585
- McpTokenStore.write("fake", { ...auth, accessToken: "access-elsewhere", refreshToken: "refresh-elsewhere" });
621
+ McpTokenStore.write(refOf(), { ...auth, accessToken: "access-elsewhere", refreshToken: "refresh-elsewhere" });
586
622
  released.resolve();
587
623
  await elsewhere;
588
624
  expect(await retried).toBe("access-elsewhere");
@@ -1,8 +1,19 @@
1
1
  import { afterEach, beforeAll, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ symlinkSync,
10
+ truncateSync,
11
+ writeFileSync,
12
+ } from "node:fs";
3
13
  import { tmpdir } from "node:os";
4
14
  import path from "node:path";
5
15
  import { akanCodePaths } from "../agent/akanCodePaths";
16
+ import { WorkspaceTrust } from "../agent/WorkspaceTrust";
6
17
  import { McpServerConfig, type McpServerScope } from "./McpServerConfig";
7
18
 
8
19
  const roots: string[] = [];
@@ -210,3 +221,121 @@ describe("MCP server config", () => {
210
221
  expect(McpServerConfig.targetOf({ name: "a", transport: "http", url: "https://x" })).toBe("https://x");
211
222
  });
212
223
  });
224
+
225
+ describe("workspace trust", () => {
226
+ const extension = (root: string, name: string, body: string) => {
227
+ const file = path.join(akanCodePaths.extensionsDir(root), name);
228
+ mkdirSync(path.dirname(file), { recursive: true });
229
+ writeFileSync(file, body);
230
+ };
231
+
232
+ afterEach(() => {
233
+ rmSync(akanCodePaths.trustFile(), { force: true });
234
+ });
235
+
236
+ test("a repo with no servers or extensions of its own needs no approval", () => {
237
+ const root = workspace();
238
+ McpServerConfig.add(root, "home", ["https://home.example/mcp"], "global");
239
+ expect(WorkspaceTrust.pending(root)).toEqual([]);
240
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
241
+ });
242
+
243
+ test("the repo's servers and extensions wait for approval, and a remembered one stays approved", () => {
244
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", args: ["x.js"] } } }));
245
+ extension(root, "hello.ts", "export default () => {};");
246
+ expect(WorkspaceTrust.pending(root).map((item) => item.kind)).toEqual(["mcp", "extensions"]);
247
+ expect(WorkspaceTrust.pending(root)[0]?.lines).toEqual(["tool: node x.js"]);
248
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
249
+ expect(WorkspaceTrust.pending(root)).toEqual([]);
250
+ expect(Object.keys(JSON.parse(readFileSync(akanCodePaths.trustFile(), "utf8")).workspaces)).toHaveLength(1);
251
+ });
252
+
253
+ test.skipIf(process.platform === "win32")("the trust file is readable only by its owner", () => {
254
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
255
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
256
+ expect(statSync(akanCodePaths.trustFile()).mode & 0o777).toBe(0o600);
257
+ });
258
+
259
+ test("a change to what was approved is asked about again", () => {
260
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", args: ["x.js"] } } }));
261
+ extension(root, "hello.ts", "export default () => {};");
262
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
263
+ writeFileSync(
264
+ McpServerConfig.file(root),
265
+ JSON.stringify({ mcpServers: { tool: { command: "node", args: ["y.js"] } } }),
266
+ );
267
+ extension(root, "nested/more.ts", "export default () => {};");
268
+ expect(WorkspaceTrust.pending(root).map((item) => item.kind)).toEqual(["mcp", "extensions"]);
269
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(false);
270
+ });
271
+
272
+ test("an approval that is not remembered lasts only for this process", () => {
273
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
274
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), false);
275
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
276
+ expect(existsSync(akanCodePaths.trustFile())).toBe(false);
277
+ });
278
+
279
+ test("an edit made through akan code keeps the approval the content had", () => {
280
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
281
+ WorkspaceTrust.approve(root, WorkspaceTrust.pending(root), true);
282
+ WorkspaceTrust.carry(root, "mcp", () =>
283
+ McpServerConfig.add(root, "more", ["https://more.example/mcp"], "workspace"),
284
+ );
285
+ expect(WorkspaceTrust.isTrusted(root, "mcp")).toBe(true);
286
+ const untrusted = workspace(JSON.stringify({ mcpServers: { tool: { command: "node" } } }));
287
+ WorkspaceTrust.carry(untrusted, "mcp", () => McpServerConfig.add(untrusted, "more", ["x"], "workspace"));
288
+ expect(WorkspaceTrust.isTrusted(untrusted, "mcp")).toBe(false);
289
+ });
290
+
291
+ test("a link that stays in the extensions folder is followed and checked", () => {
292
+ const root = workspace();
293
+ extension(root, "real/index.ts", "export default () => {};");
294
+ symlinkSync(
295
+ path.join(akanCodePaths.extensionsDir(root), "real"),
296
+ path.join(akanCodePaths.extensionsDir(root), "alias"),
297
+ );
298
+ const [item] = WorkspaceTrust.pending(root);
299
+ expect(item?.lines).toEqual(["alias/index.ts"]);
300
+ expect(item?.hash).not.toBe("");
301
+ WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
302
+ extension(root, "real/index.ts", "export default () => 1;");
303
+ expect(WorkspaceTrust.isTrusted(root, "extensions")).toBe(false);
304
+ });
305
+
306
+ test("a link out of the extensions folder is not followed, and an approval of that folder is never remembered", () => {
307
+ const root = workspace();
308
+ const outside = workspace();
309
+ writeFileSync(path.join(outside, "secret-notes.ts"), "export default () => {};");
310
+ extension(root, "hello.ts", "export default () => {};");
311
+ symlinkSync(outside, path.join(akanCodePaths.extensionsDir(root), "linked"));
312
+ const [item] = WorkspaceTrust.pending(root);
313
+ expect(item?.hash).toBe("");
314
+ expect(item?.lines[0]).toContain("linked links out of the folder");
315
+ expect(item?.lines.join("\n")).not.toContain("secret-notes");
316
+ WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
317
+ expect(existsSync(akanCodePaths.trustFile())).toBe(false);
318
+ });
319
+
320
+ test("an extensions folder past the file or byte cap is not checked, and its approval is never remembered", () => {
321
+ const many = workspace();
322
+ const modules = path.join(akanCodePaths.extensionsDir(many), "node_modules", "dep");
323
+ mkdirSync(modules, { recursive: true });
324
+ for (let index = 0; index <= 10_000; index++) writeFileSync(path.join(modules, `f${index}.js`), "");
325
+ const large = workspace();
326
+ extension(large, "big.js", "");
327
+ truncateSync(path.join(akanCodePaths.extensionsDir(large), "big.js"), 64 * 1024 * 1024 + 1);
328
+ for (const root of [many, large]) {
329
+ const [item] = WorkspaceTrust.pending(root);
330
+ expect(item?.hash).toBe("");
331
+ expect(item?.lines[0]).toContain("too many to check");
332
+ WorkspaceTrust.approve(root, [item as NonNullable<typeof item>], true);
333
+ }
334
+ expect(existsSync(akanCodePaths.trustFile())).toBe(false);
335
+ });
336
+
337
+ test("a disabled server needs no approval", () => {
338
+ const root = workspace(JSON.stringify({ mcpServers: { tool: { command: "node", disabled: true } } }));
339
+ expect(WorkspaceTrust.pending(root)).toEqual([]);
340
+ });
341
+ });
@@ -436,7 +436,10 @@ export const runCommands = async (...commands: CommandCls[]) => {
436
436
  reportFailure = async (failure) =>
437
437
  await onFailure?.(failure, { usage: false, given: givenOptionsOf(allArgMetas, opt) });
438
438
  const commandArgs = [] as unknown[];
439
- if (targetMeta.targetOption.runsOnWorkspaceRoot) await assertCurrentDirectoryIsWorkspaceRoot();
439
+ if (targetMeta.targetOption.runsOnWorkspaceRoot) {
440
+ await assertCurrentDirectoryIsWorkspaceRoot();
441
+ WorkspaceExecutor.defaultCloneToLocalEnv(process.cwd());
442
+ }
440
443
  const workspace = WorkspaceExecutor.fromRoot();
441
444
  const commandContext: CommandContext = { values: {} };
442
445
  for (const argMeta of allArgMetas) {
@@ -23,14 +23,24 @@ describe("WorkspaceVersionCheck", () => {
23
23
  expect(WorkspaceVersionCheck.warning("3.1.1-rc.5", argv("sync", "demo"), await makeWorkspace({}))).toBeNull();
24
24
  });
25
25
 
26
- test("names both versions and the matching CLI with the same arguments", async () => {
26
+ test("names both versions and the matching CLI with the same arguments, quoted for the shell it runs in", async () => {
27
27
  const root = await makeWorkspace({ akanjs: "3.1.0-rc.6" });
28
-
29
- const warning = WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo", "--title", "it's"), root);
30
-
31
- expect(warning).toContain("@akanjs/cli 3.1.0, but this workspace has akanjs 3.1.0-rc.6");
32
- expect(warning).toContain(`Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title 'it'\\''s'`);
33
- expect(warning).toEndWith("together: akan update");
28
+ const platform = Object.getOwnPropertyDescriptor(process, "platform");
29
+ const warningOn = (os: NodeJS.Platform) => {
30
+ Object.defineProperty(process, "platform", { value: os });
31
+ return WorkspaceVersionCheck.warning("3.1.0", argv("sync", "demo", "--title", "it's"), root);
32
+ };
33
+ try {
34
+ const posix = warningOn("linux");
35
+ expect(posix).toContain("@akanjs/cli 3.1.0, but this workspace has akanjs 3.1.0-rc.6");
36
+ expect(posix).toContain(`Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title 'it'\\''s'`);
37
+ expect(posix).toEndWith("together: akan update");
38
+ expect(warningOn("win32")).toContain(
39
+ `Run the matching CLI: bunx -p @akanjs/cli@3.1.0-rc.6 akan sync demo --title "it's"`,
40
+ );
41
+ } finally {
42
+ if (platform) Object.defineProperty(process, "platform", platform);
43
+ }
34
44
  });
35
45
 
36
46
  test("offers akan update with the CLI's prerelease tag only when the CLI is the newer one", async () => {
@@ -45,6 +45,9 @@ export class WorkspaceVersionCheck {
45
45
  }
46
46
 
47
47
  static #quote(arg: string) {
48
- return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
48
+ if (/^[\w@%+=:,./-]+$/.test(arg)) return arg;
49
+ //? cmd and PowerShell take neither single quotes nor '\'' as POSIX shells do.
50
+ if (process.platform === "win32") return `"${arg.replaceAll('"', '\\"')}"`;
51
+ return `'${arg.replaceAll("'", `'\\''`)}'`;
49
52
  }
50
53
  }