@akanjs/devkit 3.1.1 → 3.1.2-rc.1
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
|
@@ -31,6 +31,10 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
31
31
|
lines.push(
|
|
32
32
|
`- Some tool calls need the user's approval (policy: ${profile.approval}). A refusal is an answer; do not retry it a different way.`,
|
|
33
33
|
);
|
|
34
|
+
if (profile.network.askOutside)
|
|
35
|
+
lines.push(
|
|
36
|
+
"- `web_fetch` to a host outside a short list of documentation sites, and a shell command that reaches the network (`curl`, `wget`, `ssh`, …), wait for the user's approval. A refusal is an answer; do not retry it a different way.",
|
|
37
|
+
);
|
|
34
38
|
lines.push(
|
|
35
39
|
"- Secrets are refused at the tool boundary. If you need a value from `.env`, ask the user for it rather than looking for another way to read the file.",
|
|
36
40
|
"",
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import {
|
|
3
6
|
CodeAgentClient,
|
|
4
7
|
type CodeAgentEvent,
|
|
@@ -119,6 +122,12 @@ describe("codeAgentPresets", () => {
|
|
|
119
122
|
expect(codeAgentPresets.pod(root).tools.mcp).toBe("off");
|
|
120
123
|
});
|
|
121
124
|
|
|
125
|
+
test("only the local profile asks before reaching a host off its list", () => {
|
|
126
|
+
expect(codeAgentPresets.local(root).network.askOutside).toContain("github.com");
|
|
127
|
+
for (const name of ["pod", "review", "web"] as const)
|
|
128
|
+
expect(codeAgentPresets[name](root).network.askOutside).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
122
131
|
test("isCodeAgentPresetName refuses an unknown name", () => {
|
|
123
132
|
expect(isCodeAgentPresetName("local")).toBe(true);
|
|
124
133
|
expect(isCodeAgentPresetName("prod")).toBe(false);
|
|
@@ -407,6 +416,116 @@ describe("CodeAgentGate bash", () => {
|
|
|
407
416
|
});
|
|
408
417
|
});
|
|
409
418
|
|
|
419
|
+
describe("CodeAgentGate secrets", () => {
|
|
420
|
+
const gate = () => new CodeAgentGate(codeAgentPresets.local(root));
|
|
421
|
+
|
|
422
|
+
test("a per-environment server env file is refused for read, write and shell, and its loader is not", () => {
|
|
423
|
+
const file = "apps/a/env/env.server.main.ts";
|
|
424
|
+
expect(gate().verdict("read", { path: file }).block).toContain("deny list");
|
|
425
|
+
expect(gate().verdict("write", { path: file }).block).toContain("deny list");
|
|
426
|
+
expect(gate().verdict("bash", { command: `cat ${file}` }).block).toBeDefined();
|
|
427
|
+
expect(gate().verdict("read", { path: "apps/a/env/env.server.ts" })).toEqual({});
|
|
428
|
+
expect(gate().verdict("read", { path: "apps/a/env/env.server.type.ts" })).toEqual({});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("the engine's own spellings of a path are read as the path they name", () => {
|
|
432
|
+
expect(gate().verdict("read", { path: "@.env" }).block).toContain("deny list");
|
|
433
|
+
expect(gate().verdict("read", { path: `file://${root}/apps/a/.env` }).block).toContain("deny list");
|
|
434
|
+
expect(gate().verdict("read", { path: "~/notes.md" }).block).toContain("outside the agent root");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("a shell command naming the home credential stores or a wildcard for a secret file is refused", () => {
|
|
438
|
+
expect(gate().verdict("bash", { command: "cat ~/.akan/code/auth.json" }).block).toBeDefined();
|
|
439
|
+
expect(gate().verdict("bash", { command: "cat $HOME/.ssh/id_ed25519" }).block).toBeDefined();
|
|
440
|
+
expect(gate().verdict("bash", { command: "cat .e?v" }).block).toBeDefined();
|
|
441
|
+
expect(gate().verdict("bash", { command: "cat .akan/code/mcp.json" }).block).toBeDefined();
|
|
442
|
+
expect(gate().verdict("bash", { command: "grep -r TODO --include=*.ts ." })).toEqual({});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("a link inside the root is judged by the file it points at", () => {
|
|
446
|
+
const linked = mkdtempSync(path.join(tmpdir(), "akan-gate-"));
|
|
447
|
+
try {
|
|
448
|
+
mkdirSync(path.join(linked, "secrets"));
|
|
449
|
+
writeFileSync(path.join(linked, "secrets", "token.json"), "{}");
|
|
450
|
+
symlinkSync(path.join(linked, "secrets", "token.json"), path.join(linked, "notes.json"));
|
|
451
|
+
const linkedGate = new CodeAgentGate(codeAgentPresets.local(linked));
|
|
452
|
+
expect(linkedGate.verdict("read", { path: "notes.json" }).block).toContain("deny list");
|
|
453
|
+
} finally {
|
|
454
|
+
rmSync(linked, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("a link inside the root that leads out of it is read, and a denied file through it is not", () => {
|
|
459
|
+
const repo = mkdtempSync(path.join(tmpdir(), "akan-gate-"));
|
|
460
|
+
const linkedLib = mkdtempSync(path.join(tmpdir(), "akan-gate-lib-"));
|
|
461
|
+
try {
|
|
462
|
+
writeFileSync(path.join(linkedLib, "index.ts"), "export {};");
|
|
463
|
+
writeFileSync(path.join(linkedLib, ".env"), "");
|
|
464
|
+
mkdirSync(path.join(repo, "node_modules"));
|
|
465
|
+
symlinkSync(linkedLib, path.join(repo, "node_modules", "lib"));
|
|
466
|
+
const linkedGate = new CodeAgentGate(codeAgentPresets.local(repo));
|
|
467
|
+
expect(linkedGate.verdict("read", { path: "node_modules/lib/index.ts" })).toEqual({});
|
|
468
|
+
expect(linkedGate.verdict("read", { path: "node_modules/lib/.env" }).block).toContain("deny list");
|
|
469
|
+
expect(linkedGate.verdict("read", { path: path.join(linkedLib, "index.ts") }).block).toContain(
|
|
470
|
+
"outside the agent root",
|
|
471
|
+
);
|
|
472
|
+
} finally {
|
|
473
|
+
rmSync(repo, { recursive: true, force: true });
|
|
474
|
+
rmSync(linkedLib, { recursive: true, force: true });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("CodeAgentGate network", () => {
|
|
480
|
+
const gate = () => new CodeAgentGate(codeAgentPresets.local(root));
|
|
481
|
+
|
|
482
|
+
test("the local profile asks before fetching a host off its list, and not for a listed one or loopback", () => {
|
|
483
|
+
expect(gate().verdict("web_fetch", { url: "https://example.com/x" })).toEqual({
|
|
484
|
+
approval: "fetch https://example.com/x",
|
|
485
|
+
strict: true,
|
|
486
|
+
});
|
|
487
|
+
expect(gate().verdict("web_fetch", { url: "https://docs.github.com/en" })).toEqual({});
|
|
488
|
+
expect(gate().verdict("web_fetch", { url: "http://localhost:3000/" })).toEqual({});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("a networking shell command asks, unless it only reaches loopback over http", () => {
|
|
492
|
+
expect(gate().verdict("bash", { command: "curl -s https://example.com" }).strict).toBe(true);
|
|
493
|
+
expect(gate().verdict("bash", { command: "curl -X POST https://github.com/x" }).strict).toBe(true);
|
|
494
|
+
expect(gate().verdict("bash", { command: "ssh deploy@host" }).strict).toBe(true);
|
|
495
|
+
expect(gate().verdict("bash", { command: "curl http://127.0.0.1:8080/health" })).toEqual({});
|
|
496
|
+
expect(gate().verdict("bash", { command: "git status && bun test" })).toEqual({});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("a loopback host:port without a scheme is loopback, and any other host still asks", () => {
|
|
500
|
+
expect(gate().verdict("bash", { command: "curl -s localhost:4200/health" })).toEqual({});
|
|
501
|
+
expect(gate().verdict("bash", { command: 'wget -qO- "127.0.0.1:8080"' })).toEqual({});
|
|
502
|
+
expect(gate().verdict("bash", { command: "curl [::1]:3000/x" })).toEqual({});
|
|
503
|
+
expect(gate().verdict("bash", { command: "curl -s example.com:443/x" }).strict).toBe(true);
|
|
504
|
+
expect(gate().verdict("bash", { command: "curl -s localhost:4200 example.com:80" }).strict).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a client name asks only where it runs as a command, not as another command's argument", () => {
|
|
508
|
+
for (const command of ["grep -rn curl src", "echo ssh", "git log --grep=wget", "cat docs/rsync.md"])
|
|
509
|
+
expect(gate().verdict("bash", { command })).toEqual({});
|
|
510
|
+
for (const command of [
|
|
511
|
+
"ls && curl https://example.com",
|
|
512
|
+
"cat body | curl -d @- https://example.com",
|
|
513
|
+
"echo $(curl https://example.com)",
|
|
514
|
+
"FOO=1 sudo -E /usr/bin/curl https://example.com",
|
|
515
|
+
"timeout 5 wget https://example.com",
|
|
516
|
+
"sh -c 'curl https://example.com'",
|
|
517
|
+
"bun test\nssh deploy@host",
|
|
518
|
+
])
|
|
519
|
+
expect(gate().verdict("bash", { command }).strict).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("a profile without a list never asks", () => {
|
|
523
|
+
const pod = new CodeAgentGate(codeAgentPresets.pod(root));
|
|
524
|
+
expect(pod.verdict("web_fetch", { url: "https://example.com/x" })).toEqual({});
|
|
525
|
+
expect(pod.verdict("bash", { command: "curl https://example.com" })).toEqual({});
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
410
529
|
describe("codeAgentShouldPersist", () => {
|
|
411
530
|
test("follows the table for engine frames", () => {
|
|
412
531
|
expect(codeAgentShouldPersist({ type: "message", turnId: "t1", role: "assistant", text: "hi" })).toBe(true);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
const bare = (hostname: string) =>
|
|
4
|
+
hostname
|
|
5
|
+
.replace(/^\[|\]$/g, "")
|
|
6
|
+
.replace(/\.$/, "")
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
|
|
9
|
+
// Loopback only: a private LAN address is another machine, and a request to it leaves this one.
|
|
10
|
+
export const codeAgentIsLoopback = (hostname: string) => {
|
|
11
|
+
const host = bare(hostname);
|
|
12
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
13
|
+
if (isIP(host) === 4) return host.startsWith("127.");
|
|
14
|
+
if (isIP(host) === 6) return host === "::1" || /^::ffff:(127\.\d+\.\d+\.\d+|7f[0-9a-f]{2}:[0-9a-f]{1,4})$/.test(host);
|
|
15
|
+
return false;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const codeAgentHostListed = (hostname: string, hosts: readonly string[]) => {
|
|
19
|
+
const host = bare(hostname);
|
|
20
|
+
return hosts.some((entry) => host === entry || host.endsWith(`.${entry}`));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const codeAgentHostOf = (url: string) => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.hostname : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { AkanCodeEnv } from "./AkanCodeEnv";
|
|
5
6
|
import { AkanCodeServices } from "./AkanCodeServices";
|
|
6
7
|
import { akanCodeDefaultModel, akanCodeModel } from "./akanCodeModel";
|
|
8
|
+
import { akanCodePaths } from "./akanCodePaths";
|
|
7
9
|
import { CodeAgentProxy } from "./CodeAgentProxy";
|
|
8
10
|
|
|
9
11
|
const seen: { path: string; auth: string | null }[] = [];
|
|
@@ -72,3 +74,53 @@ describe("CodeAgentProxy", () => {
|
|
|
72
74
|
expect(() => new CodeAgentProxy({ baseUrl: "https://proxy", providers: ["deepseek"] })).toThrow("TOKEN");
|
|
73
75
|
});
|
|
74
76
|
});
|
|
77
|
+
|
|
78
|
+
describe("AkanCodeEnv", () => {
|
|
79
|
+
const inRepo = (dotenv: string | null, run: () => void) => {
|
|
80
|
+
const repo = mkdtempSync(path.join(tmpdir(), "akan-code-env-"));
|
|
81
|
+
const cwd = process.cwd();
|
|
82
|
+
const had = process.env.AKAN_CODE_PROXY_URL;
|
|
83
|
+
try {
|
|
84
|
+
if (dotenv !== null) writeFileSync(path.join(repo, ".env"), dotenv);
|
|
85
|
+
process.chdir(repo);
|
|
86
|
+
process.env.AKAN_CODE_PROXY_URL = "https://proxy.example/{provider}";
|
|
87
|
+
run();
|
|
88
|
+
} finally {
|
|
89
|
+
process.chdir(cwd);
|
|
90
|
+
if (had === undefined) delete process.env.AKAN_CODE_PROXY_URL;
|
|
91
|
+
else process.env.AKAN_CODE_PROXY_URL = had;
|
|
92
|
+
rmSync(repo, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
test("a setting the repo's own dotenv file names is ignored, and the start warning says so", () => {
|
|
97
|
+
inRepo("export AKAN_CODE_PROXY_URL=placeholder\n", () => {
|
|
98
|
+
expect(AkanCodeEnv.get("AKAN_CODE_PROXY_URL")).toBeUndefined();
|
|
99
|
+
expect(CodeAgentProxy.fromEnv()).toBeNull();
|
|
100
|
+
expect(AkanCodeEnv.ignored()).toEqual([expect.stringContaining("AKAN_CODE_PROXY_URL is set in .env")]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a setting from the shell is read when no dotenv file names it", () => {
|
|
105
|
+
inRepo("OTHER_KEY=placeholder\n", () => {
|
|
106
|
+
expect(AkanCodeEnv.get("AKAN_CODE_PROXY_URL")).toBe("https://proxy.example/{provider}");
|
|
107
|
+
expect(AkanCodeEnv.ignored()).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test.skipIf(process.platform === "win32")(
|
|
112
|
+
"the home directory is made owner-only, even one made looser before",
|
|
113
|
+
() => {
|
|
114
|
+
const loose = path.join(home, "loose-home");
|
|
115
|
+
mkdirSync(loose, { mode: 0o755 });
|
|
116
|
+
chmodSync(loose, 0o755);
|
|
117
|
+
process.env.AKAN_CODE_HOME = loose;
|
|
118
|
+
try {
|
|
119
|
+
akanCodePaths.ensureGlobalDir();
|
|
120
|
+
expect(statSync(loose).mode & 0o777).toBe(0o700);
|
|
121
|
+
} finally {
|
|
122
|
+
process.env.AKAN_CODE_HOME = home;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { CodeAgentEvent } from "akanjs/common";
|
|
@@ -44,23 +44,28 @@ class FakeAgent {
|
|
|
44
44
|
|
|
45
45
|
class Client {
|
|
46
46
|
lines: unknown[] = [];
|
|
47
|
+
readonly closed = Promise.withResolvers<void>();
|
|
47
48
|
#buffer = "";
|
|
48
49
|
#socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
49
|
-
static async connect(
|
|
50
|
+
static async connect(target: string | { port: number }) {
|
|
50
51
|
const client = new Client();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
client.#buffer = parts.pop() ?? "";
|
|
58
|
-
for (const part of parts) if (part) client.lines.push(JSON.parse(part));
|
|
59
|
-
},
|
|
52
|
+
const socket = {
|
|
53
|
+
data: (_socket: unknown, data: Buffer) => {
|
|
54
|
+
client.#buffer += data.toString();
|
|
55
|
+
const parts = client.#buffer.split("\n");
|
|
56
|
+
client.#buffer = parts.pop() ?? "";
|
|
57
|
+
for (const part of parts) if (part) client.lines.push(JSON.parse(part));
|
|
60
58
|
},
|
|
61
|
-
|
|
59
|
+
close: () => client.closed.resolve(),
|
|
60
|
+
};
|
|
61
|
+
client.#socket = await (typeof target === "string"
|
|
62
|
+
? Bun.connect({ unix: target, socket })
|
|
63
|
+
: Bun.connect({ hostname: "127.0.0.1", port: target.port, socket }));
|
|
62
64
|
return client;
|
|
63
65
|
}
|
|
66
|
+
raw(text: string) {
|
|
67
|
+
this.#socket?.write(text);
|
|
68
|
+
}
|
|
64
69
|
send(id: string, command: object) {
|
|
65
70
|
this.#socket?.write(`${JSON.stringify({ id, command })}\n`);
|
|
66
71
|
}
|
|
@@ -81,10 +86,17 @@ describe("CodeAgentRpcListener", () => {
|
|
|
81
86
|
test("parses unix and tcp addresses, defaulting tcp to loopback", () => {
|
|
82
87
|
expect(CodeAgentRpcListener.parse("unix:/tmp/a.sock")).toEqual({ unix: "/tmp/a.sock" });
|
|
83
88
|
expect(CodeAgentRpcListener.parse("tcp:7000")).toEqual({ hostname: "127.0.0.1", port: 7000 });
|
|
84
|
-
expect(CodeAgentRpcListener.parse("
|
|
89
|
+
expect(CodeAgentRpcListener.parse("tcp::7000")).toEqual({ hostname: "127.0.0.1", port: 7000 });
|
|
90
|
+
expect(CodeAgentRpcListener.parse("localhost:7000")).toEqual({ hostname: "localhost", port: 7000 });
|
|
85
91
|
expect(() => CodeAgentRpcListener.parse("tcp:nope")).toThrow("--rpc-listen");
|
|
86
92
|
});
|
|
87
93
|
|
|
94
|
+
test("a bind other machines could reach is refused unless it is asked for", () => {
|
|
95
|
+
expect(() => CodeAgentRpcListener.parse("0.0.0.0:7000")).toThrow("--rpc-bind-any");
|
|
96
|
+
expect(() => CodeAgentRpcListener.parse("tcp:192.168.0.2:7000")).toThrow("--rpc-bind-any");
|
|
97
|
+
expect(CodeAgentRpcListener.parse("0.0.0.0:7000", { anyHost: true })).toEqual({ hostname: "0.0.0.0", port: 7000 });
|
|
98
|
+
});
|
|
99
|
+
|
|
88
100
|
test("the agent outlives a dropped client, and the next one catches up by seq", async () => {
|
|
89
101
|
const agent = new FakeAgent();
|
|
90
102
|
const unix = path.join(dir, "agent.sock");
|
|
@@ -110,4 +122,94 @@ describe("CodeAgentRpcListener", () => {
|
|
|
110
122
|
await served;
|
|
111
123
|
expect(agent.disposed).toBe(true);
|
|
112
124
|
});
|
|
125
|
+
|
|
126
|
+
describe("over tcp", () => {
|
|
127
|
+
const opened = () => {
|
|
128
|
+
const agent = new FakeAgent();
|
|
129
|
+
const listener = new CodeAgentRpcListener(new CodeAgentRpcHost(agent as unknown as CodeAgent, null), {
|
|
130
|
+
hostname: "127.0.0.1",
|
|
131
|
+
port: 0,
|
|
132
|
+
}).listen();
|
|
133
|
+
return { agent, listener, port: listener.port ?? 0 };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
test("a connection opened with the token is answered, and one without it is closed unheard", async () => {
|
|
137
|
+
const { agent, listener, port } = opened();
|
|
138
|
+
try {
|
|
139
|
+
expect(listener.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
140
|
+
const anonymous = await Client.connect({ port });
|
|
141
|
+
anonymous.send("1", { type: "prompt", message: "hi" });
|
|
142
|
+
await anonymous.closed.promise;
|
|
143
|
+
expect(anonymous.lines).toEqual([{ type: "reply", id: "auth", ok: false, error: "unauthorized" }]);
|
|
144
|
+
|
|
145
|
+
const wrong = await Client.connect({ port });
|
|
146
|
+
wrong.raw(`${JSON.stringify({ auth: "not-the-token" })}\n`);
|
|
147
|
+
await wrong.closed.promise;
|
|
148
|
+
expect(agent.seq).toBe(1);
|
|
149
|
+
|
|
150
|
+
const client = await Client.connect({ port });
|
|
151
|
+
client.raw(
|
|
152
|
+
`${JSON.stringify({ auth: listener.token })}\n${JSON.stringify({ id: "2", command: { type: "prompt", message: "hi" } })}\n`,
|
|
153
|
+
);
|
|
154
|
+
expect((await client.reply("auth")).ok).toBe(true);
|
|
155
|
+
expect((await client.reply("2")).ok).toBe(true);
|
|
156
|
+
expect(client.lines.findIndex((line) => (line as { id?: string }).id === "auth")).toBe(0);
|
|
157
|
+
client.close();
|
|
158
|
+
} finally {
|
|
159
|
+
listener.stop();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a connection that speaks anything but a JSON frame is dropped at its first byte", async () => {
|
|
164
|
+
const { listener, port } = opened();
|
|
165
|
+
try {
|
|
166
|
+
const browser = await Client.connect({ port });
|
|
167
|
+
browser.raw("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n");
|
|
168
|
+
await browser.closed.promise;
|
|
169
|
+
expect(browser.lines).toEqual([]);
|
|
170
|
+
} finally {
|
|
171
|
+
listener.stop();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a connection that has not authenticated does not displace the client that has", async () => {
|
|
176
|
+
const { listener, port } = opened();
|
|
177
|
+
try {
|
|
178
|
+
const client = await Client.connect({ port });
|
|
179
|
+
client.raw(`${JSON.stringify({ auth: listener.token })}\n`);
|
|
180
|
+
await client.reply("auth");
|
|
181
|
+
const other = await Client.connect({ port });
|
|
182
|
+
other.raw("{");
|
|
183
|
+
await Bun.sleep(30);
|
|
184
|
+
client.send("3", { type: "get_state", sinceSeq: 0 });
|
|
185
|
+
expect((await client.reply("3")).ok).toBe(true);
|
|
186
|
+
other.close();
|
|
187
|
+
client.close();
|
|
188
|
+
} finally {
|
|
189
|
+
listener.stop();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test.skipIf(process.platform === "win32")(
|
|
195
|
+
"a unix socket is its owner's alone, and needs no token, though one is accepted",
|
|
196
|
+
async () => {
|
|
197
|
+
const agent = new FakeAgent();
|
|
198
|
+
const unix = path.join(dir, "owned.sock");
|
|
199
|
+
const listener = new CodeAgentRpcListener(new CodeAgentRpcHost(agent as unknown as CodeAgent, null), { unix });
|
|
200
|
+
listener.listen();
|
|
201
|
+
try {
|
|
202
|
+
expect(listener.token).toBeNull();
|
|
203
|
+
expect(statSync(unix).mode & 0o777).toBe(0o600);
|
|
204
|
+
const client = await Client.connect(unix);
|
|
205
|
+
client.raw(`${JSON.stringify({ auth: "anything" })}\n`);
|
|
206
|
+
expect((await client.reply("auth")).ok).toBe(true);
|
|
207
|
+
client.send("4", { type: "get_state", sinceSeq: 0 });
|
|
208
|
+
expect((await client.reply("4")).ok).toBe(true);
|
|
209
|
+
client.close();
|
|
210
|
+
} finally {
|
|
211
|
+
listener.stop();
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
);
|
|
113
215
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const stringArg = (args: unknown, key: "path" | "command") => {
|
|
1
|
+
export const stringArg = (args: unknown, key: "path" | "command" | "url") => {
|
|
2
2
|
if (!args || typeof args !== "object") return undefined;
|
|
3
3
|
const value = (args as Record<string, unknown>)[key];
|
|
4
4
|
return typeof value === "string" && value ? value : undefined;
|
|
@@ -14,7 +14,10 @@ agent nothing, and only the current page's declarations are live.
|
|
|
14
14
|
1. Mount `<Agent.Chat />` once, in a layout. It never executes tools: every tool runs in the caller's browser,
|
|
15
15
|
gated by guards and the approval card.
|
|
16
16
|
2. It refuses every call until `option.setAgentAccess(<guard>)` in `lib/option.ts` names who may use it
|
|
17
|
-
(`SignedIn` for a product with accounts). What a turn may ask is `option.setAgentRequestPolicy
|
|
17
|
+
(`SignedIn` for a product with accounts). What a turn may ask is `option.setAgentRequestPolicy`; until one is
|
|
18
|
+
set, a turn past `AgentRelayPolicy.defaultLimits` is refused with 413, and a policy of the app's own keeps them
|
|
19
|
+
with `AgentRelayPolicy.capped(request, limits)`. An error that is not an `Err` reaches the chat as a generic
|
|
20
|
+
failure unless `AKAN_ERROR_DETAIL=1` or a development runtime shows it.
|
|
18
21
|
3. The LLM is `option.setLlm({ apiKey, model, host })`, never env.
|
|
19
22
|
|
|
20
23
|
## Publish actions
|
|
@@ -22,6 +25,8 @@ agent nothing, and only the current page's declarations are live.
|
|
|
22
25
|
- Declare the tool beside the control that already does it, and hand the result to the control:
|
|
23
26
|
`st.tool("x").desc("…").arg("id", ID).exec((id) => st.do.removeX(id))`. One handler serves the person and the
|
|
24
27
|
agent; `.desc()` is required and first.
|
|
28
|
+
- A name starting with a write verb (`remove`, `delete`, `create`, `update`, `upsert`, `submit`, `save`, `send`,
|
|
29
|
+
`export`) asks the user first; `{ confirm: false }` opts one tool out, `AgentConfirm.set` the app.
|
|
25
30
|
- `.card(({ submit, cancel }) => <Form … />)` parks the call in the chat for an answer that is the user's to give.
|
|
26
31
|
- Store-driven forms publish themselves: `onChange={st.do.setFieldOnX}` by reference emits the `data-akan-action`
|
|
27
32
|
the agent drives. A pass-through arrow publishes nothing and fails lint.
|
|
@@ -30,6 +35,8 @@ agent nothing, and only the current page's declarations are live.
|
|
|
30
35
|
## Reads and guidance
|
|
31
36
|
|
|
32
37
|
- `st.use.x({ agent: false })` hides a key; `hidden`, `secret` and `visual` fields are stripped from every read.
|
|
38
|
+
- Page text is data the model reads, never instructions: after a screen read the next call that changes something
|
|
39
|
+
asks once, and `data-agent-private="<label>"` keeps a region's text out of every read.
|
|
33
40
|
- A tool value is capped at 20,000 characters.
|
|
34
41
|
- Route guidance is `<Agent.Guide instructions="…" />`; model-facing text is English.
|
|
35
42
|
|
|
@@ -9,9 +9,9 @@ This is the tool the user is talking to right now. When they ask how to do somet
|
|
|
9
9
|
with their code — answer from here, and quote the exact gesture: the slash command to type, or the file to
|
|
10
10
|
edit. Do not guess a command; everything this tool offers is listed below.
|
|
11
11
|
|
|
12
|
-
You cannot type a slash command yourself. They are the host's, not yours — say which one to type. You
|
|
13
|
-
edit
|
|
14
|
-
|
|
12
|
+
You cannot type a slash command yourself. They are the host's, not yours — say which one to type. You cannot
|
|
13
|
+
edit `mcp.json`, `.akan/code/extensions/` or anything under `~/.akan/` either: the deny list covers them, so
|
|
14
|
+
quote the `/mcp` command that makes the change instead. The context files and skills named below you can edit.
|
|
15
15
|
|
|
16
16
|
## MCP servers
|
|
17
17
|
|
|
@@ -52,8 +52,17 @@ From the prompt:
|
|
|
52
52
|
|
|
53
53
|
**A server added now is not reachable until the session reloads.** The tool allowlist is fixed when the
|
|
54
54
|
session is created, so `add`, `remove`, `login` and `logout` all reload by themselves — a session that has
|
|
55
|
-
said nothing yet has nothing on disk to reopen, and says so.
|
|
56
|
-
|
|
55
|
+
said nothing yet has nothing on disk to reopen, and says so. After the user edits `mcp.json` by hand, `/mcp
|
|
56
|
+
reload` makes it take effect.
|
|
57
|
+
|
|
58
|
+
**This repo's own servers start only once the user approves them.** At start, `akan code` lists what this
|
|
59
|
+
repo's `.akan/code/mcp.json` would run, and the extensions in `.akan/code/extensions/`, and asks once; a yes is
|
|
60
|
+
remembered in `~/.akan/code/trust.json` until that content changes, and is asked again after. An extensions
|
|
61
|
+
folder past 10,000 files or 64 MiB, or with a link that leads out of it, is asked about at every start. With
|
|
62
|
+
nobody at a terminal they are not started, and `/mcp` shows `not approved`. `--trust-workspace` (`-T`) starts
|
|
63
|
+
them for one run without asking. Servers in `~/.akan/code/mcp.json`, where `/mcp add` writes, need no approval;
|
|
64
|
+
`/mcp add --local` writes this repo's file and keeps the approval it had, so a server added while that file
|
|
65
|
+
waits for approval waits with it.
|
|
57
66
|
|
|
58
67
|
`/mcp remove` on a name both files declare takes this repo's entry and leaves the home file's in force; its
|
|
59
68
|
notice says so, and running it again removes that one too.
|
|
@@ -61,8 +70,9 @@ notice says so, and running it again removes that one too.
|
|
|
61
70
|
Sign-in is discovered, not configured: the server's `401` names its resource metadata, that names the
|
|
62
71
|
authorization server, and most hosted servers register a client on demand — so a url is usually the whole
|
|
63
72
|
entry. For a provider that issues client ids by hand, add `"oauth": { "clientId": "…", "scope": "…" }`.
|
|
64
|
-
Tokens are stored
|
|
65
|
-
|
|
73
|
+
Tokens are stored in `~/.akan/code/mcpAuth.json` at mode 0600, never in the repo, bound to the server's origin
|
|
74
|
+
rather than its name — a server renamed in the file keeps its sign-in, and one moved to another url signs in
|
|
75
|
+
again. They are refreshed silently — at connect when one has expired, and mid-session when a server refuses one, after which the call is
|
|
66
76
|
retried once. When a refresh cannot fix it, `/mcp` shows `sign-in needed` and that server's tools answer with
|
|
67
77
|
`/mcp login <name>` until the user signs in; the sign-in reloads the session, which brings the server back.
|
|
68
78
|
|
|
@@ -89,16 +99,19 @@ Stored under `.akan/code/sessions` — project history, so a second checkout of
|
|
|
89
99
|
- A provider is authorized by **`<PROVIDER>_API_KEY`** — `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`. The
|
|
90
100
|
workspace's own `.env` is read as well as the environment, and a key found there is used for the run and
|
|
91
101
|
never copied anywhere.
|
|
92
|
-
- Credentials and the model catalogue live in `~/.akan/code/` (`auth.json`, `models.json`).
|
|
93
|
-
moves the whole directory** — for a container with no home, or a checkout that wants its
|
|
102
|
+
- Credentials and the model catalogue live in `~/.akan/code/` (`auth.json`, `models.json`), mode 0700.
|
|
103
|
+
**`AKAN_CODE_HOME` moves the whole directory** — for a container with no home, or a checkout that wants its
|
|
104
|
+
own. It, `AKAN_CODE_CAN_PROMPT`, the `AKAN_CODE_PROXY_*` settings and `AKAN_CODE_RPC_TOKEN_FILE` are read
|
|
105
|
+
from the shell only: one a repo's `.env` names is ignored, with a warning at start.
|
|
94
106
|
|
|
95
107
|
## What the agent reads, and how to change it
|
|
96
108
|
|
|
97
109
|
- Every `AGENTS.md` / `CLAUDE.md` from the workspace root down to the working directory, `.cursorrules`, and
|
|
98
110
|
any `.cursor/rules/*.mdc` that applies unconditionally (`alwaysApply: true`, or no frontmatter at all).
|
|
99
111
|
Edit those to change how this agent behaves in this repo — they are the files the editor beside it reads.
|
|
100
|
-
- Skills: drop a `<name>/SKILL.md` into `.akan/code/skills`. Extensions: `.akan/code/extensions
|
|
101
|
-
read from a home directory, so a session behaves the
|
|
112
|
+
- Skills: drop a `<name>/SKILL.md` into `.akan/code/skills`. Extensions: `.akan/code/extensions`, loaded once
|
|
113
|
+
the user approves them (see MCP servers). Neither is read from a home directory, so a session behaves the
|
|
114
|
+
same in two checkouts.
|
|
102
115
|
|
|
103
116
|
## Keys
|
|
104
117
|
|
|
@@ -124,8 +137,21 @@ akan code -i "<prompt>" open the session with the prompt already sent
|
|
|
124
137
|
--profile local|pod|review|web what it may do (default local)
|
|
125
138
|
--model <provider>/<id> --thinking, --json, --rpc, --resume <id>
|
|
126
139
|
--compact-at <percent> compact once the window is this full (10, 20, … 100)
|
|
140
|
+
--trust-workspace start this repo's MCP servers and extensions without asking
|
|
141
|
+
--no-ask-network never ask before an unlisted host (local profile)
|
|
142
|
+
--rpc-listen unix:<path>|tcp:[host:]<port> serve the RPC wire on a socket instead of stdio
|
|
127
143
|
```
|
|
128
144
|
|
|
145
|
+
`local` runs as the user: it refuses known secret files and the home credential stores, and asks before it
|
|
146
|
+
fetches a page from a host off a short documentation list or a shell command that reaches the network
|
|
147
|
+
(`curl`, `ssh`, …) — refused outright where nobody can answer, as in a one-shot `akan code "<prompt>"`. It is
|
|
148
|
+
not a sandbox; `pod` in a container is.
|
|
149
|
+
|
|
150
|
+
A `tcp:` listener asks every connection to open with `{"auth":"<token>"}` on its first line; the token is a
|
|
151
|
+
fresh one in `~/.akan/code/rpc/<port>.token`, or the one in the file `AKAN_CODE_RPC_TOKEN_FILE` names.
|
|
152
|
+
`--no-rpc-token` drops it, and only a loopback address is bound unless `--rpc-bind-any` is given. A unix
|
|
153
|
+
socket is created owner-only and asks for no token.
|
|
154
|
+
|
|
129
155
|
`review` reads and never writes, loads no project context files, and keeps no session on disk. `pod` has
|
|
130
156
|
nobody in front of it: it cannot ask a question. MCP is off in both.
|
|
131
157
|
|
|
@@ -14,7 +14,8 @@ the server errors logged meanwhile, console errors and warnings, and layout smel
|
|
|
14
14
|
- It uses the dev server that answers for this app and checkout. With none, it starts one for the run and
|
|
15
15
|
stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
|
|
16
16
|
running one.
|
|
17
|
-
- Paths are comma-separated and get the default locale when they lack
|
|
17
|
+
- Paths are comma-separated (`akan preview <app> /board,/board/new`) and get the default locale when they lack
|
|
18
|
+
one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off.
|
|
18
19
|
`--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
|
|
19
20
|
path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
|
|
20
21
|
- The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
|
|
@@ -28,7 +28,8 @@ import path at all**, so keep throwing code out of them entirely rather than rea
|
|
|
28
28
|
|
|
29
29
|
Best-effort code returns a sentinel (`null`, `undefined`, `{}`) instead of throwing. `try/catch` is rare and
|
|
30
30
|
always converts an exception into a decision: a guard catches, warns, and returns `false`; an adapter
|
|
31
|
-
catches, logs, and returns `null`. A store action never catches — let the framework toast the `Err
|
|
31
|
+
catches, logs, and returns `null`. A store action never catches — let the framework toast the `Err`, or list it in
|
|
32
|
+
the store's `static silent` when no screen waits on it and its caller handles the rejection.
|
|
32
33
|
Client-side validation failure is `msg.error("<key>")` plus an early return, never a throw.
|
|
33
34
|
|
|
34
35
|
**A failure another process reported travels as itself.** A server-to-server `fetch.<endpoint>(…, { origin })`
|
|
@@ -25,8 +25,9 @@ description: "Write filters, slices and list loading: query descriptors, init pa
|
|
|
25
25
|
`purgeMany`, for data past retention.
|
|
26
26
|
- A filter's `remove*` / `update*`, `updateById` and `removeById` run no hooks and no cascade. A model with a
|
|
27
27
|
cascade or `_postRemove` removes through `remove<Model>(id)`.
|
|
28
|
-
- Load a set once (`load<Model>Many`, one filter query)
|
|
29
|
-
per-item service call an endpoint reaches.
|
|
28
|
+
- Load a set once (`load<Model>Many`, one filter query) and create one with `create<Model>Many(rows)` instead of one
|
|
29
|
+
call per item in a loop — lint fails a per-item service call an endpoint reaches. `create<Model>Many` runs every
|
|
30
|
+
row's `_preCreate` before writing any, so a check there does not see the batch's own rows.
|
|
30
31
|
|
|
31
32
|
## Search and cascade
|
|
32
33
|
|
|
@@ -17,6 +17,7 @@ Domain code reaches none of them.
|
|
|
17
17
|
| The production image | `docker: string \| { image, preRuns, postRuns, command }` |
|
|
18
18
|
| iOS, Android, desktop | the `native` section; `desktop: { server: true }` carries the server |
|
|
19
19
|
| SQLite, several databases, a cluster | `database: { modes: [...] }`, picked by `AKAN_DATABASE_MODE` |
|
|
20
|
+
| URLs without `/<locale>/`: one language, or paths fixed from outside (OAuth callback, QR code, mail link) | `i18n: { localePrefix: "as-needed" }` — the default locale's pages and `<Link>` hrefs drop the prefix, other locales keep it |
|
|
20
21
|
|
|
21
22
|
- `main.ts` imports `AkanApp` from `akanjs/server/akanApp`, never the barrel.
|
|
22
23
|
- Never write `/api` or `/ws` literally, and never pass a prefix as a prop.
|
|
@@ -28,6 +29,8 @@ Domain code reaches none of them.
|
|
|
28
29
|
- `akan start <app>` (`a,b` or `all` for several) runs the dev server; `akan build <app>` checks the production
|
|
29
30
|
build. A supervised session writes `local/apps/<app>/runtime/dev.log` — hand over that path, not a paste of the
|
|
30
31
|
live view. Tail with `akan logs <app>`.
|
|
32
|
+
- Run a build without Docker with `cd dist/apps/<app> && bun main.js`: it boots with the env the Dockerfile sets,
|
|
33
|
+
read from `akan.env.json` beside it, and a variable you set yourself (`PORT=3000`) wins.
|
|
31
34
|
- Give every log sink a floor (`Logger.addSink(sink, { minLevel })`) and never log once per delivered record.
|
|
32
35
|
|
|
33
36
|
## Native and desktop
|
|
@@ -42,16 +42,18 @@ export class McpServerConfig {
|
|
|
42
42
|
];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
static read(workspaceRoot: string): CodeAgentMcpServerRef[] {
|
|
46
|
-
return McpServerConfig.declared(workspaceRoot)
|
|
45
|
+
static read(workspaceRoot: string, scopes?: McpServerScope[]): CodeAgentMcpServerRef[] {
|
|
46
|
+
return McpServerConfig.declared(workspaceRoot, scopes)
|
|
47
47
|
.filter((entry) => !entry.disabled)
|
|
48
48
|
.map((entry) => entry.ref);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** Every declared server including the disabled ones, which is what a listing has to show. */
|
|
52
|
-
static declared(workspaceRoot: string): McpDeclaredServer[] {
|
|
52
|
+
static declared(workspaceRoot: string, scopes?: McpServerScope[]): McpDeclaredServer[] {
|
|
53
53
|
const byName = new Map<string, McpDeclaredServer>();
|
|
54
|
-
for (const { scope } of McpServerConfig.files(workspaceRoot)
|
|
54
|
+
for (const { scope } of McpServerConfig.files(workspaceRoot).filter(
|
|
55
|
+
(file) => !scopes || scopes.includes(file.scope),
|
|
56
|
+
))
|
|
55
57
|
for (const [name, entry] of Object.entries(McpServerConfig.#parse(workspaceRoot, scope).map))
|
|
56
58
|
byName.set(name, { ref: McpServerConfig.#refOf(name, entry), disabled: !!entry.disabled, scope });
|
|
57
59
|
return [...byName.values()];
|