@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.
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
@@ -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(unix: string) {
50
+ static async connect(target: string | { port: number }) {
50
51
  const client = new Client();
51
- client.#socket = await Bun.connect({
52
- unix,
53
- socket: {
54
- data: (_socket, data) => {
55
- client.#buffer += data.toString();
56
- const parts = client.#buffer.split("\n");
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("0.0.0.0:7000")).toEqual({ hostname: "0.0.0.0", port: 7000 });
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 *can*
13
- edit the files named below, which is the useful half of most of these answers: write the change, then say
14
- which command makes it take effect.
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. If you edit `mcp.json` yourself, tell the user to
56
- run `/mcp reload`.
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 per server in `~/.akan/code/mcpAuth.json` at mode 0600, never in the repo, and refreshed
65
- silently — at connect when one has expired, and mid-session when a server refuses one, after which the call is
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`). **`AKAN_CODE_HOME`
93
- moves the whole directory** — for a container with no home, or a checkout that wants its own.
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`. Neither is
101
- read from a home directory, so a session behaves the same in two checkouts.
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 one: `akan preview <app> /board,/board/new`.
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) instead of one call per item in a loop — lint fails a
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()];