@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.
- package/akanConfig/akanConfig.test.ts +75 -0
- package/akanConfig/akanConfig.ts +11 -1
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +36 -0
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.ts +4 -2
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +7 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +4 -0
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/skills/akan-agent/SKILL.md +8 -1
- package/codeAgent/skills/akan-code/SKILL.md +37 -11
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-query/SKILL.md +3 -2
- package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -1
- package/executors.ts +41 -2
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/integration/ssrMemoryProbe.ts +6 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/maintainabilityReport.ts +1 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +3 -2
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.ts +22 -81
- package/sshHostKeys.ts +59 -0
- package/storeSource.ts +9 -8
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/workflow/mutationScaffold.ts +35 -21
- package/workflow/plan.ts +28 -0
- 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
|
|
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
|
|
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 = `${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
34
|
-
|
|
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
|
|
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(
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
McpTokenStore.#save(
|
|
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 #
|
|
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 {
|
|
66
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
redirect: "follow"
|
|
179
|
-
|
|
180
|
-
|
|
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("
|
|
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(
|
|
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(
|
|
273
|
+
McpTokenStore.write(refOf(), { ...auth, expiresAt: Date.now() - 1 });
|
|
274
274
|
expect(await McpSignIn.token(refOf())).toBe("access-refreshed-2");
|
|
275
|
-
expect(McpTokenStore.read(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
499
|
-
expect(McpTokenStore.read(
|
|
500
|
-
expect(McpTokenStore.clear(
|
|
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(
|
|
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(
|
|
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");
|