@akanjs/devkit 3.1.1 → 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 (80) hide show
  1. package/akanConfig/akanConfig.test.ts +75 -0
  2. package/akanConfig/akanConfig.ts +11 -1
  3. package/akanMcpContract.test.ts +28 -1
  4. package/akanMcpContract.ts +51 -3
  5. package/applicationBuildRunner.test.ts +36 -0
  6. package/applicationBuildRunner.ts +4 -3
  7. package/artifact/implicitRootLayout.ts +4 -2
  8. package/cloud/globalConfig.test.ts +34 -0
  9. package/cloud/globalConfig.ts +2 -2
  10. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  11. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  12. package/codeAgent/agent/CodeAgent.ts +7 -0
  13. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  14. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  15. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  16. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  17. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  18. package/codeAgent/agent/akanCodePaths.ts +8 -1
  19. package/codeAgent/agent/akanSystemPrompt.ts +4 -0
  20. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  21. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  22. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  23. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  24. package/codeAgent/agent/stringArg.ts +1 -1
  25. package/codeAgent/skills/akan-agent/SKILL.md +8 -1
  26. package/codeAgent/skills/akan-code/SKILL.md +37 -11
  27. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  28. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  29. package/codeAgent/skills/akan-query/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
  31. package/codeAgent/tools/McpServerConfig.ts +6 -4
  32. package/codeAgent/tools/McpSignIn.ts +7 -7
  33. package/codeAgent/tools/McpTokenStore.ts +63 -16
  34. package/codeAgent/tools/McpToolPack.ts +8 -1
  35. package/codeAgent/tools/WebToolPack.ts +50 -6
  36. package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
  37. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  38. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  39. package/commandDecorators/command.ts +4 -1
  40. package/createTunnel.test.ts +200 -2
  41. package/createTunnel.ts +80 -6
  42. package/envSeed.test.ts +90 -0
  43. package/envSeed.ts +68 -0
  44. package/executors.test.ts +19 -1
  45. package/executors.ts +41 -2
  46. package/fileSys.test.ts +30 -2
  47. package/fileSys.ts +14 -0
  48. package/formSetterScanner.test.ts +44 -1
  49. package/formSetterScanner.ts +53 -8
  50. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  51. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  52. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  53. package/frontendBuild/optionalPeers.test.ts +132 -0
  54. package/integration/ssrMemoryProbe.ts +6 -0
  55. package/lint/no-unguarded-endpoint.grit +5 -5
  56. package/maintainabilityReport.ts +1 -1
  57. package/mobile/desktopServerStage.test.ts +1 -0
  58. package/mobile/desktopServerStage.ts +3 -1
  59. package/mobile/nativeApp.test.ts +20 -5
  60. package/mobile/nativeApp.ts +4 -1
  61. package/mobile/nativeConfig.test.ts +209 -5
  62. package/mobile/nativeConfig.ts +127 -6
  63. package/mobile/nativePublicEnv.test.ts +2 -0
  64. package/mobile/nativePublicEnv.ts +13 -0
  65. package/package.json +3 -2
  66. package/shellOnlyEnv.test.ts +65 -0
  67. package/shellOnlyEnv.ts +54 -0
  68. package/signalMutationIndex.ts +22 -81
  69. package/sshHostKeys.ts +59 -0
  70. package/storeSource.ts +9 -8
  71. package/subspace.test.ts +10 -0
  72. package/subspaceClone.ts +3 -1
  73. package/subspaceConfig.ts +6 -0
  74. package/transforms/barrelImportsPlugin.ts +10 -4
  75. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  76. package/transforms/mcpAppPageTrimmer.ts +5 -20
  77. package/transforms/pureLazyMarker.ts +1 -1
  78. package/workflow/mutationScaffold.ts +35 -21
  79. package/workflow/plan.ts +28 -0
  80. package/workflow/sourceAst.ts +10 -48
@@ -16,7 +16,7 @@ export class McpSignIn {
16
16
 
17
17
  /** The token to connect with, or undefined when none is had without asking; a failed refresh is not a throw. */
18
18
  static async token(ref: CodeAgentMcpServerRef, onNotice?: (message: string) => void) {
19
- const stored = McpTokenStore.read(ref.name);
19
+ const stored = McpTokenStore.read(ref);
20
20
  if (!stored) return undefined;
21
21
  if (!McpTokenStore.isExpired(stored)) return stored.accessToken;
22
22
  return await McpSignIn.#refreshOnce(ref, stored, onNotice);
@@ -24,14 +24,14 @@ export class McpSignIn {
24
24
 
25
25
  /** After a server answered `refused` with 401: a token another session stored since, else one refresh. */
26
26
  static async retryToken(ref: CodeAgentMcpServerRef, refused: string, onNotice?: (message: string) => void) {
27
- const stored = McpTokenStore.read(ref.name);
27
+ const stored = McpTokenStore.read(ref);
28
28
  if (!stored) return undefined;
29
29
  if (stored.accessToken !== refused && !McpTokenStore.isExpired(stored)) return stored.accessToken;
30
30
  return await McpSignIn.#refreshOnce(ref, stored, onNotice);
31
31
  }
32
32
 
33
33
  static async #refreshOnce(ref: CodeAgentMcpServerRef, stored: McpStoredAuth, onNotice?: (message: string) => void) {
34
- const key = `${ref.name}\n${stored.refreshToken}`;
34
+ const key = `${stored.issuer}\n${stored.refreshToken}`;
35
35
  let pending = McpSignIn.#refreshing.get(key);
36
36
  if (!pending) {
37
37
  pending = McpSignIn.#refreshed(ref, stored, onNotice).finally(() => McpSignIn.#refreshing.delete(key));
@@ -45,7 +45,7 @@ export class McpSignIn {
45
45
  try {
46
46
  return await ConfigLock.run(McpTokenStore.lockFile(), async () => {
47
47
  // Another `akan code` may have refreshed while this one waited: take its token, never its spent refresh token.
48
- const current = McpTokenStore.read(ref.name);
48
+ const current = McpTokenStore.read(ref);
49
49
  if (!current) return undefined;
50
50
  if (current.accessToken !== stored.accessToken && !McpTokenStore.isExpired(current)) return current.accessToken;
51
51
  const server: McpOAuthServer = {
@@ -58,7 +58,7 @@ export class McpSignIn {
58
58
  // `expires_in` is optional (RFC 6749 §5.1): the spent token's expiry must not outlive it on the new one.
59
59
  const { expiresAt: _spent, ...grant } = current;
60
60
  // A server that rotates refresh tokens invalidates the old one, so keeping it would sign us out.
61
- McpTokenStore.write(ref.name, { ...grant, ...tokens });
61
+ McpTokenStore.write(ref, { ...grant, ...tokens });
62
62
  return tokens.accessToken;
63
63
  });
64
64
  } catch (error) {
@@ -72,7 +72,7 @@ export class McpSignIn {
72
72
  if (!ref.url) throw new Error(`"${ref.name}" is a stdio server: give it its credential through "env".`);
73
73
  const challenge = await McpSignIn.challenge(ref);
74
74
  const server = await McpOAuth.discover(ref.url, challenge);
75
- const stored = McpTokenStore.read(ref.name);
75
+ const stored = McpTokenStore.read(ref);
76
76
  const reusable = stored && stored.issuer === server.issuer && !ref.oauth?.clientId ? stored : undefined;
77
77
  const loopback = McpOAuthLoopback.bind(McpSignIn.#portOf(reusable?.redirectUri));
78
78
  try {
@@ -98,7 +98,7 @@ export class McpSignIn {
98
98
  redirectUri,
99
99
  ...tokens,
100
100
  };
101
- McpTokenStore.write(ref.name, auth);
101
+ McpTokenStore.write(ref, auth);
102
102
  return auth;
103
103
  } finally {
104
104
  loopback.close();
@@ -20,7 +20,18 @@ export interface McpStoredAuth {
20
20
  scope?: string;
21
21
  }
22
22
 
23
+ export interface McpTokenOwner {
24
+ name: string;
25
+ url?: string | undefined;
26
+ }
27
+
28
+ interface McpTokenEntry extends McpStoredAuth {
29
+ name: string;
30
+ origin: string;
31
+ }
32
+
23
33
  // Home directory only, mode 0600: `.akan/code/mcp.json` is shared with the editors and routinely committed.
34
+ // Keyed by the server's origin, not its name: a workspace `mcp.json` can give a known name any url.
24
35
  export class McpTokenStore {
25
36
  static file() {
26
37
  return akanCodePaths.mcpAuthFile();
@@ -30,24 +41,37 @@ export class McpTokenStore {
30
41
  return `${McpTokenStore.file()}.lock`;
31
42
  }
32
43
 
33
- static read(name: string): McpStoredAuth | undefined {
34
- return McpTokenStore.#all()[name];
44
+ static read(owner: McpTokenOwner): McpStoredAuth | undefined {
45
+ const origin = McpTokenStore.#originOf(owner.url);
46
+ if (!origin) return undefined;
47
+ const here = McpTokenStore.#all().filter((entry) => entry.origin === origin);
48
+ const entry = here.find((candidate) => candidate.name === owner.name) ?? here[0];
49
+ if (!entry) return undefined;
50
+ const { name: _name, origin: _origin, ...auth } = entry;
51
+ return auth;
35
52
  }
36
53
 
37
54
  static names() {
38
- return Object.keys(McpTokenStore.#all());
55
+ return [...new Set(McpTokenStore.#all().map((entry) => entry.name))];
39
56
  }
40
57
 
41
58
  /** Re-reads right before writing, or two concurrent sign-ins each drop the other's token. */
42
- static write(name: string, auth: McpStoredAuth) {
43
- McpTokenStore.#save({ ...McpTokenStore.#all(), [name]: auth });
59
+ static write(owner: McpTokenOwner, auth: McpStoredAuth) {
60
+ const origin = McpTokenStore.#originOf(owner.url);
61
+ if (!origin) throw new Error(`"${owner.name}" has no http(s) url to bind its token to.`);
62
+ const kept = McpTokenStore.#all().filter(
63
+ (entry) => entry.origin !== origin || (entry.issuer !== auth.issuer && entry.name !== owner.name),
64
+ );
65
+ McpTokenStore.#save([...kept, { ...auth, name: owner.name, origin }]);
44
66
  }
45
67
 
46
- static clear(name: string) {
68
+ //? Everything `read` could hand this server goes: its origin's tokens under any name, and its name's at any origin.
69
+ static clear(owner: McpTokenOwner) {
70
+ const origin = McpTokenStore.#originOf(owner.url);
47
71
  const all = McpTokenStore.#all();
48
- if (!(name in all)) return false;
49
- delete all[name];
50
- McpTokenStore.#save(all);
72
+ const kept = all.filter((entry) => entry.name !== owner.name && entry.origin !== origin);
73
+ if (kept.length === all.length) return false;
74
+ McpTokenStore.#save(kept);
51
75
  return true;
52
76
  }
53
77
 
@@ -58,22 +82,45 @@ export class McpTokenStore {
58
82
  /** A token is treated as spent this long before it actually is, so a call never races its own expiry. */
59
83
  static readonly skewMs = 60_000;
60
84
 
61
- static #all(): Record<string, McpStoredAuth> {
85
+ static #originOf(url: string | undefined) {
86
+ if (!url) return undefined;
87
+ try {
88
+ const parsed = new URL(url);
89
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : undefined;
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ static #keyOf(entry: McpTokenEntry) {
96
+ return `${entry.origin} ${entry.issuer}`;
97
+ }
98
+
99
+ static #all(): McpTokenEntry[] {
62
100
  const file = McpTokenStore.file();
63
- if (!existsSync(file)) return {};
101
+ if (!existsSync(file)) return [];
64
102
  try {
65
- const parsed = JSON.parse(readFileSync(file, "utf8")) as { servers?: Record<string, McpStoredAuth> };
66
- return parsed.servers ?? {};
103
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as {
104
+ tokens?: Record<string, McpTokenEntry>;
105
+ servers?: Record<string, McpStoredAuth>;
106
+ };
107
+ if (parsed.tokens) return Object.values(parsed.tokens);
108
+ // An older build keyed by name alone; its resource is the url the token was minted for, so it keeps that origin.
109
+ return Object.entries(parsed.servers ?? {}).flatMap(([name, auth]) => {
110
+ const origin = McpTokenStore.#originOf(auth.resource);
111
+ return origin ? [{ ...auth, name, origin }] : [];
112
+ });
67
113
  } catch {
68
- return {};
114
+ return [];
69
115
  }
70
116
  }
71
117
 
72
- static #save(servers: Record<string, McpStoredAuth>) {
118
+ static #save(entries: McpTokenEntry[]) {
73
119
  const file = McpTokenStore.file();
74
120
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
121
+ const tokens = Object.fromEntries(entries.map((entry) => [McpTokenStore.#keyOf(entry), entry]));
75
122
  const temp = `${file}.${process.pid}.tmp`;
76
- writeFileSync(temp, `${JSON.stringify({ servers }, null, 2)}\n`, { mode: 0o600 });
123
+ writeFileSync(temp, `${JSON.stringify({ tokens }, null, 2)}\n`, { mode: 0o600 });
77
124
  renameSync(temp, file);
78
125
  // `rename` keeps the temp file's mode, but an existing target written by an older build may be looser.
79
126
  chmodSync(file, 0o600);
@@ -7,6 +7,7 @@ import {
7
7
  codeAgentOutputChars,
8
8
  } from "akanjs/common";
9
9
  import { Type } from "typebox";
10
+ import { WorkspaceTrust } from "../agent/WorkspaceTrust";
10
11
  import { McpClient, type McpToolInfo, McpUnauthorized } from "./McpClient";
11
12
  import { McpServerConfig } from "./McpServerConfig";
12
13
  import { McpSignIn } from "./McpSignIn";
@@ -194,6 +195,12 @@ export class McpToolPack {
194
195
  static #refs(options: McpToolPackOptions): CodeAgentMcpServerRef[] {
195
196
  if (options.profile.tools.mcp === "off") return [];
196
197
  const declared = Array.isArray(options.profile.tools.mcp) ? options.profile.tools.mcp : [];
197
- return [...McpServerConfig.read(options.workspaceRoot), ...declared];
198
+ if (WorkspaceTrust.isTrusted(options.workspaceRoot, "mcp"))
199
+ return [...McpServerConfig.read(options.workspaceRoot), ...declared];
200
+ const skipped = McpServerConfig.read(options.workspaceRoot, ["workspace"]).map((ref) => ref.name);
201
+ options.onNotice?.(
202
+ `Not started, because this repo's MCP servers are not approved: ${skipped.join(", ")}. Start akan code in a terminal to approve them, or pass --trust-workspace.`,
203
+ );
204
+ return [...McpServerConfig.read(options.workspaceRoot, ["global"]), ...declared];
198
205
  }
199
206
  }
@@ -3,6 +3,7 @@ import { isIP } from "node:net";
3
3
  import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
4
4
  import { type CodeAgentProfile, codeAgentClip } from "akanjs/common";
5
5
  import { Type } from "typebox";
6
+ import { codeAgentHostListed, codeAgentHostOf, codeAgentIsLoopback } from "../agent/codeAgentHosts";
6
7
  import { DeepSeekWeb } from "./DeepSeekWeb";
7
8
 
8
9
  export interface WebToolPackOptions {
@@ -34,6 +35,7 @@ interface WebPage {
34
35
  export class WebToolPack {
35
36
  static readonly pageChars = 10_000;
36
37
  static readonly maxResults = 10;
38
+ static readonly maxRedirects = 5;
37
39
  static readonly #rememberedPages = 16;
38
40
  readonly #options: WebToolPackOptions;
39
41
  // A continuation reads the snapshot its first page came from; a fresh read always refetches, since a local dev
@@ -164,8 +166,10 @@ export class WebToolPack {
164
166
  }
165
167
 
166
168
  async #read(url: string, signal?: AbortSignal): Promise<WebPage> {
169
+ const within = this.#boundOf(new URL(url).hostname);
167
170
  const deepseek = this.#options.deepseek;
168
- if (deepseek && !(await this.#isLocal(new URL(url).hostname))) {
171
+ // DeepSeek follows a redirect on its own servers, where no host list reaches it.
172
+ if (deepseek && !within && !(await this.#isLocal(new URL(url).hostname))) {
169
173
  try {
170
174
  const page = await deepseek.fetch(url, signal);
171
175
  return { status: 200, ok: true, title: page.title, text: page.content };
@@ -173,11 +177,51 @@ export class WebToolPack {
173
177
  if (signal?.aborted) throw error;
174
178
  }
175
179
  }
176
- const response = await fetch(url, {
177
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000),
178
- redirect: "follow",
179
- });
180
- return { status: response.status, ok: response.ok, title: "", text: WebToolPack.#readable(await response.text()) };
180
+ const timeout = signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000);
181
+ if (!within) {
182
+ const response = await fetch(url, { signal: timeout, redirect: "follow" });
183
+ return {
184
+ status: response.status,
185
+ ok: response.ok,
186
+ title: "",
187
+ text: WebToolPack.#readable(await response.text()),
188
+ };
189
+ }
190
+ return await WebToolPack.#readWithin(url, within, timeout);
191
+ }
192
+
193
+ #boundOf(hostname: string): ((host: string) => boolean) | undefined {
194
+ const { allowHosts, askOutside } = this.#options.profile.network;
195
+ if (allowHosts?.length) return (host) => allowHosts.includes(host);
196
+ const quiet = (host: string) => codeAgentIsLoopback(host) || codeAgentHostListed(host, askOutside ?? []);
197
+ return askOutside && quiet(hostname) ? quiet : undefined;
198
+ }
199
+
200
+ static async #readWithin(url: string, within: (host: string) => boolean, signal: AbortSignal): Promise<WebPage> {
201
+ let current = url;
202
+ for (let hop = 0; hop <= WebToolPack.maxRedirects; hop += 1) {
203
+ const response = await fetch(current, { signal, redirect: "manual" });
204
+ const location = response.headers.get("location");
205
+ if (response.status < 300 || response.status >= 400 || !location)
206
+ return {
207
+ status: response.status,
208
+ ok: response.ok,
209
+ title: "",
210
+ text: WebToolPack.#readable(await response.text()),
211
+ };
212
+ await response.body?.cancel();
213
+ const next = new URL(location, current).href;
214
+ const host = codeAgentHostOf(next);
215
+ if (!host || !within(host))
216
+ return {
217
+ status: response.status,
218
+ ok: false,
219
+ title: "",
220
+ text: `Redirected to ${next}, which this profile does not reach without asking. web_fetch that URL to follow it.`,
221
+ };
222
+ current = next;
223
+ }
224
+ return { status: 310, ok: false, title: "", text: `More than ${WebToolPack.maxRedirects} redirects.` };
181
225
  }
182
226
 
183
227
  #remember(url: string, page: WebPage) {
@@ -1,9 +1,12 @@
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
11
  import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
9
12
  import { AkanCodePlugins } from "./AkanCodePlugins";
@@ -19,6 +22,26 @@ import { WebToolPack } from "./WebToolPack";
19
22
  const track = tempRoots();
20
23
  const tempRoot = async () => track(await makeCliTempWorkspace()).root;
21
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
+
22
45
  type ToolExecute = (
23
46
  id: string,
24
47
  params: never,
@@ -554,8 +577,20 @@ for await (const line of console) {
554
577
  return root;
555
578
  };
556
579
 
557
- 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 () => {
558
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());
559
594
  const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.local(root) });
560
595
  expect(pack).toBeDefined();
561
596
  try {
@@ -582,6 +617,7 @@ for await (const line of console) {
582
617
  path.join(root, ".akan", "code", "mcp.json"),
583
618
  JSON.stringify({ mcpServers: { broken: { command: "/nonexistent/binary" } } }),
584
619
  );
620
+ approved(root);
585
621
  const notices: string[] = [];
586
622
  const pack = await McpToolPack.connect({
587
623
  workspaceRoot: root,
@@ -656,6 +692,38 @@ describe("WebToolPack", () => {
656
692
  }
657
693
  });
658
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
+
659
727
  test("reads a long page a window at a time, continuing from the snapshot it first read", async () => {
660
728
  let hits = 0;
661
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");