@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
|
@@ -1,28 +1,72 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
1
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
2
5
|
import type { CodeAgentProfile } from "akanjs/common";
|
|
6
|
+
import { akanCodePaths } from "./akanCodePaths";
|
|
7
|
+
import { codeAgentHostListed, codeAgentHostOf, codeAgentIsLoopback } from "./codeAgentHosts";
|
|
3
8
|
import { stringArg } from "./stringArg";
|
|
4
9
|
|
|
5
10
|
const writeTools = new Set(["write", "edit"]);
|
|
6
11
|
const commandTools = new Set(["bash"]);
|
|
12
|
+
// A client in command position (after a separator, `sh -c '`, `-exec`), past `VAR=value` and wrappers like `timeout 5`.
|
|
13
|
+
// Gaps are spaces and tabs only: a newline is itself a separator, and letting `\s` take it too backtracks polynomially.
|
|
14
|
+
const networkClients =
|
|
15
|
+
/(?:^[ \t]*|[\n;&|(`{][ \t]*|\s-\w*c\s+['"][ \t]*|\s-exec(?:dir)?\s+)(?:(?:[A-Za-z_]\w*=\S*|sudo|env|command|exec|nohup|time|timeout|nice|watch|stdbuf|xargs|-\S+|\d\S*)[ \t]+)*(?:[^\s;&|()`'"]*\/)?(curl|wget|nc|ncat|netcat|socat|telnet|ssh|scp|sftp|rsync|ftp)(?=$|[\s;&|)`'"])/g;
|
|
16
|
+
const urlPattern = /\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>|;&()]+/gi;
|
|
17
|
+
// curl and wget read a `host:port` argument without a scheme as http.
|
|
18
|
+
const hostPortPattern = /(?<=^|[\s'"=])(?:\[[0-9a-f:.]+\]|[a-z0-9.-]+):\d{1,5}(?=[/?#\s'"]|$)/gi;
|
|
19
|
+
// A glob in a command that also matches these names is a broad sweep (`*.ts`), not an aim at one secret file.
|
|
20
|
+
const ordinaryNames = ["index.ts", "a.ts", "a.tsx", "a.js", "a.json", "a.yaml", "a.md", "a.txt", "a.log", "README"];
|
|
7
21
|
|
|
8
22
|
export interface GateVerdict {
|
|
9
23
|
/** `undefined` means the call may run without asking. */
|
|
10
24
|
block?: string;
|
|
11
25
|
approval?: string;
|
|
26
|
+
/** Refused, not run, when nobody can be asked: the call would leave the machine. */
|
|
27
|
+
strict?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface NameRule {
|
|
31
|
+
glob: Bun.Glob;
|
|
32
|
+
samples: string[];
|
|
12
33
|
}
|
|
13
34
|
|
|
14
35
|
// Gated here, not in the engine's file-ops port: a throw there reads to the model as a tool bug, and it retries.
|
|
15
36
|
export class CodeAgentGate {
|
|
16
37
|
readonly #profile: CodeAgentProfile;
|
|
38
|
+
readonly #realRoot: string;
|
|
17
39
|
readonly #deny: Bun.Glob[];
|
|
18
40
|
readonly #allow: Bun.Glob[];
|
|
19
41
|
readonly #deniedFragments: string[];
|
|
42
|
+
readonly #nameRules: NameRule[];
|
|
20
43
|
|
|
21
44
|
constructor(profile: CodeAgentProfile) {
|
|
22
45
|
this.#profile = profile;
|
|
23
|
-
this.#
|
|
46
|
+
this.#realRoot = CodeAgentGate.#real(path.resolve(profile.paths.root));
|
|
47
|
+
const home = homedir();
|
|
48
|
+
const codeHome = path.resolve(akanCodePaths.globalDir());
|
|
49
|
+
const patterns = [
|
|
50
|
+
...(profile.paths.deny ?? []).map((pattern) =>
|
|
51
|
+
pattern.startsWith("~/") ? path.join(home, pattern.slice(2)) : pattern,
|
|
52
|
+
),
|
|
53
|
+
...(profile.paths.deny?.length ? [`${codeHome}/**`] : []),
|
|
54
|
+
];
|
|
55
|
+
this.#deny = patterns.map((pattern) => new Bun.Glob(pattern));
|
|
24
56
|
this.#allow = (profile.paths.allow ?? []).map((pattern) => new Bun.Glob(pattern));
|
|
25
|
-
this.#deniedFragments = [
|
|
57
|
+
this.#deniedFragments = [
|
|
58
|
+
...new Set(
|
|
59
|
+
(profile.paths.deny ?? [])
|
|
60
|
+
.flatMap(CodeAgentGate.#expandBraces)
|
|
61
|
+
.flatMap((pattern) => CodeAgentGate.#fragmentsOf(pattern, home)),
|
|
62
|
+
),
|
|
63
|
+
].filter(Boolean);
|
|
64
|
+
this.#nameRules = (profile.paths.deny ?? []).flatMap((pattern) => {
|
|
65
|
+
const name = /^\*\*\/([^/]+)$/.exec(pattern)?.[1];
|
|
66
|
+
if (!name || name === "**") return [];
|
|
67
|
+
const samples = CodeAgentGate.#expandBraces(name).map((entry) => entry.replace(/[*?]/g, "x"));
|
|
68
|
+
return [{ glob: new Bun.Glob(name), samples }];
|
|
69
|
+
});
|
|
26
70
|
}
|
|
27
71
|
|
|
28
72
|
verdict(toolName: string, args: unknown): GateVerdict {
|
|
@@ -36,6 +80,8 @@ export class CodeAgentGate {
|
|
|
36
80
|
const denied = this.#deniedCommandReason(command);
|
|
37
81
|
if (denied) return { block: denied };
|
|
38
82
|
}
|
|
83
|
+
const network = this.#networkApproval(toolName, args);
|
|
84
|
+
if (network) return { approval: network, strict: true };
|
|
39
85
|
if (!this.#needsApproval(toolName)) return {};
|
|
40
86
|
return { approval: CodeAgentGate.#summarize(toolName, args) };
|
|
41
87
|
}
|
|
@@ -43,23 +89,67 @@ export class CodeAgentGate {
|
|
|
43
89
|
// A speed bump, not a boundary: a shell reaches any path; the real boundary is the `pod` profile's container.
|
|
44
90
|
// It stops the obvious accident (`cat .env`) from persisting a live key into the transcript.
|
|
45
91
|
#deniedCommandReason(command: string) {
|
|
46
|
-
const hit =
|
|
92
|
+
const hit =
|
|
93
|
+
this.#deniedFragments.find((fragment) => command.includes(fragment)) ??
|
|
94
|
+
CodeAgentGate.#wordsOf(command).find((word) => this.#namesDeniedFile(word));
|
|
47
95
|
if (!hit) return undefined;
|
|
48
96
|
return `The command names "${hit}", which the profile's deny list covers. Ask the user for the value instead of reading the file.`;
|
|
49
97
|
}
|
|
50
98
|
|
|
99
|
+
#namesDeniedFile(word: string) {
|
|
100
|
+
const expanded = CodeAgentGate.#expandHome(word);
|
|
101
|
+
const name = path.basename(expanded);
|
|
102
|
+
if (/[*?[{]/.test(name)) {
|
|
103
|
+
const swept = new Bun.Glob(name);
|
|
104
|
+
if (ordinaryNames.some((ordinary) => swept.match(ordinary))) return false;
|
|
105
|
+
return this.#nameRules.some((rule) => rule.samples.some((sample) => swept.match(sample)));
|
|
106
|
+
}
|
|
107
|
+
const absolute = path.resolve(this.#profile.paths.root, expanded);
|
|
108
|
+
return this.#deny.some((glob) => glob.match(expanded) || glob.match(absolute));
|
|
109
|
+
}
|
|
110
|
+
|
|
51
111
|
#deniedReason(target: string) {
|
|
52
|
-
const absolute = path.
|
|
53
|
-
const relative = path.relative(this.#profile.paths.root, absolute);
|
|
54
|
-
if (relative.startsWith(
|
|
112
|
+
const absolute = path.resolve(this.#profile.paths.root, CodeAgentGate.#expandPath(target));
|
|
113
|
+
const relative = path.relative(path.resolve(this.#profile.paths.root), absolute);
|
|
114
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
55
115
|
return `Path is outside the agent root (${this.#profile.paths.root}).`;
|
|
56
|
-
|
|
116
|
+
// The deny list also judges the real path, so a link inside the root cannot lend a denied file an innocent name;
|
|
117
|
+
// the root is judged as written, since a linked package or lib leads out of it on purpose.
|
|
118
|
+
const real = CodeAgentGate.#real(absolute);
|
|
119
|
+
const candidates = [
|
|
120
|
+
[absolute, relative],
|
|
121
|
+
[real, path.relative(this.#realRoot, real)],
|
|
122
|
+
];
|
|
123
|
+
if (candidates.some(([candidate, rel]) => this.#deny.some((glob) => glob.match(candidate) || glob.match(rel))))
|
|
57
124
|
return "Path is on the profile's deny list (secrets and environment files are never readable).";
|
|
58
125
|
if (this.#allow.length && !this.#allow.some((glob) => glob.match(absolute) || glob.match(relative)))
|
|
59
126
|
return "Path is outside the profile's allow list.";
|
|
60
127
|
return undefined;
|
|
61
128
|
}
|
|
62
129
|
|
|
130
|
+
#networkApproval(toolName: string, args: unknown) {
|
|
131
|
+
const quiet = this.#profile.network.askOutside;
|
|
132
|
+
if (!quiet) return undefined;
|
|
133
|
+
if (toolName === "web_fetch") {
|
|
134
|
+
const url = stringArg(args, "url");
|
|
135
|
+
const host = url ? codeAgentHostOf(url) : undefined;
|
|
136
|
+
if (!host || codeAgentIsLoopback(host) || codeAgentHostListed(host, quiet)) return undefined;
|
|
137
|
+
return `fetch ${url}`;
|
|
138
|
+
}
|
|
139
|
+
const command = stringArg(args, "command");
|
|
140
|
+
if (!command || !commandTools.has(toolName)) return undefined;
|
|
141
|
+
const clients = [...command.matchAll(networkClients)].map((match) => match[1]);
|
|
142
|
+
if (!clients.length) return undefined;
|
|
143
|
+
// Only loopback is exempt here: a listed host still takes an upload under someone else's account.
|
|
144
|
+
const hosts = [
|
|
145
|
+
...[...command.matchAll(urlPattern)].map((match) => codeAgentHostOf(match[0])),
|
|
146
|
+
...[...command.matchAll(hostPortPattern)].map((match) => codeAgentHostOf(`http://${match[0]}`)),
|
|
147
|
+
];
|
|
148
|
+
const httpOnly = clients.every((client) => client === "curl" || client === "wget");
|
|
149
|
+
if (httpOnly && hosts.length && hosts.every((host) => host && codeAgentIsLoopback(host))) return undefined;
|
|
150
|
+
return `reach the network: ${command.slice(0, 200)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
#needsApproval(toolName: string) {
|
|
64
154
|
switch (this.#profile.approval) {
|
|
65
155
|
case "never":
|
|
@@ -75,13 +165,69 @@ export class CodeAgentGate {
|
|
|
75
165
|
}
|
|
76
166
|
}
|
|
77
167
|
|
|
168
|
+
// The engine's own path reading: `@` is stripped, `~` and `file://` expand, odd spaces become plain ones.
|
|
169
|
+
static #expandPath(target: string) {
|
|
170
|
+
const value = target.replace(/[ - ]/g, " ").replace(/^@/, "");
|
|
171
|
+
if (/^file:\/\//.test(value)) {
|
|
172
|
+
try {
|
|
173
|
+
return fileURLToPath(value);
|
|
174
|
+
} catch {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return CodeAgentGate.#expandHome(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
static #expandHome(value: string) {
|
|
182
|
+
const home = /^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/.exec(value);
|
|
183
|
+
return home ? path.join(homedir(), value.slice(home[0].length)) : value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
static #real(absolute: string) {
|
|
187
|
+
const rest: string[] = [];
|
|
188
|
+
let head = absolute;
|
|
189
|
+
for (;;) {
|
|
190
|
+
try {
|
|
191
|
+
return path.join(realpathSync(head), ...rest);
|
|
192
|
+
} catch {
|
|
193
|
+
const parent = path.dirname(head);
|
|
194
|
+
if (parent === head) return absolute;
|
|
195
|
+
rest.unshift(path.basename(head));
|
|
196
|
+
head = parent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
static #wordsOf(command: string) {
|
|
202
|
+
return command.split(/[\s;&|<>()`'"=,@]+/).filter(Boolean);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
static #expandBraces(pattern: string): string[] {
|
|
206
|
+
const open = pattern.indexOf("{");
|
|
207
|
+
const close = open >= 0 ? pattern.indexOf("}", open) : -1;
|
|
208
|
+
if (close < 0) return [pattern];
|
|
209
|
+
const head = pattern.slice(0, open);
|
|
210
|
+
const tail = pattern.slice(close + 1);
|
|
211
|
+
return pattern
|
|
212
|
+
.slice(open + 1, close)
|
|
213
|
+
.split(",")
|
|
214
|
+
.flatMap((choice) => CodeAgentGate.#expandBraces(`${head}${choice}${tail}`));
|
|
215
|
+
}
|
|
216
|
+
|
|
78
217
|
// A glob cannot match a shell line, so a deny pattern is reduced to a literal; a mid-pattern wildcard is dropped.
|
|
79
|
-
static #
|
|
218
|
+
static #fragmentsOf(pattern: string, home: string) {
|
|
219
|
+
if (pattern.startsWith("~/")) {
|
|
220
|
+
const rest = pattern.slice(2).replace(/\/\*\*$/, "");
|
|
221
|
+
return rest.includes("*")
|
|
222
|
+
? []
|
|
223
|
+
: ["~/", "$HOME/", "$" + "{HOME}/", `${home}/`].map((prefix) => `${prefix}${rest}`);
|
|
224
|
+
}
|
|
80
225
|
const trimmed = pattern
|
|
81
226
|
.replace(/^\*\*\//, "")
|
|
82
227
|
.replace(/\/\*\*$/, "/")
|
|
83
|
-
.replace(/^\*+/, "")
|
|
84
|
-
|
|
228
|
+
.replace(/^\*+/, "")
|
|
229
|
+
.replace(/\*+$/, "");
|
|
230
|
+
return trimmed.includes("*") ? [] : [trimmed];
|
|
85
231
|
}
|
|
86
232
|
|
|
87
233
|
static #summarize(toolName: string, args: unknown) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { type CodeAgentProfile, trimTrailingSlashes } from "akanjs/common";
|
|
4
|
+
import { AkanCodeEnv } from "./AkanCodeEnv";
|
|
4
5
|
import { akanCodeDefaultModel } from "./akanCodeModel";
|
|
5
6
|
|
|
6
7
|
interface PreparedRequest {
|
|
@@ -36,17 +37,17 @@ export class CodeAgentProxy {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
static fromEnv(profile?: CodeAgentProfile): CodeAgentProxy | null {
|
|
39
|
-
const baseUrl =
|
|
40
|
+
const baseUrl = AkanCodeEnv.get("AKAN_CODE_PROXY_URL")?.trim() || profile?.network.proxyBaseUrl;
|
|
40
41
|
if (!baseUrl) return null;
|
|
41
|
-
const providers = (
|
|
42
|
+
const providers = (AkanCodeEnv.get("AKAN_CODE_PROXY_PROVIDERS") ?? akanCodeDefaultModel.provider)
|
|
42
43
|
.split(",")
|
|
43
44
|
.map((provider) => provider.trim())
|
|
44
45
|
.filter(Boolean);
|
|
45
46
|
return new CodeAgentProxy({
|
|
46
47
|
baseUrl,
|
|
47
48
|
providers,
|
|
48
|
-
tokenFile:
|
|
49
|
-
token:
|
|
49
|
+
tokenFile: AkanCodeEnv.get("AKAN_CODE_PROXY_TOKEN_FILE")?.trim() || undefined,
|
|
50
|
+
token: AkanCodeEnv.get("AKAN_CODE_PROXY_TOKEN")?.trim() || undefined,
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -55,6 +55,9 @@ export class CodeAgentRpcHost {
|
|
|
55
55
|
} catch {
|
|
56
56
|
return this.#emit({ type: "error", message: "malformed command frame", fatal: false, seq: -1 });
|
|
57
57
|
}
|
|
58
|
+
// A client that always opens with its token still connects where none is asked for.
|
|
59
|
+
if (request && typeof request === "object" && "auth" in request && !request.command)
|
|
60
|
+
return this.#reply({ type: "reply", id: "auth", ok: true });
|
|
58
61
|
try {
|
|
59
62
|
this.#reply({ type: "reply", id: request.id, ok: true, data: await this.#run(request.command) });
|
|
60
63
|
} catch (error) {
|
|
@@ -1,40 +1,67 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, rmSync } from "node:fs";
|
|
2
3
|
import type { Socket, SocketListener } from "bun";
|
|
3
4
|
import type { CodeAgentRpcHost } from "./CodeAgentRpcHost";
|
|
5
|
+
import { codeAgentIsLoopback } from "./codeAgentHosts";
|
|
4
6
|
|
|
5
7
|
export type CodeAgentRpcAddress = { unix: string } | { hostname: string; port: number };
|
|
6
8
|
|
|
9
|
+
export interface CodeAgentRpcListenerOptions {
|
|
10
|
+
token?: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
interface ClientState {
|
|
8
14
|
pending: Buffer | null;
|
|
9
15
|
decoder: TextDecoder;
|
|
16
|
+
admitted: boolean;
|
|
17
|
+
preface: string;
|
|
18
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
// One client at a time: a reconnecting control plane replaces a socket that has not timed out yet.
|
|
13
|
-
//
|
|
22
|
+
// A connection is a client only once its first line authenticates: until then it cannot displace the current one.
|
|
14
23
|
export class CodeAgentRpcListener {
|
|
24
|
+
static readonly prefaceLimit = 4_096;
|
|
25
|
+
static readonly prefaceMs = 5_000;
|
|
15
26
|
readonly #host: CodeAgentRpcHost;
|
|
16
27
|
readonly #address: CodeAgentRpcAddress;
|
|
28
|
+
readonly #token: string | null;
|
|
17
29
|
#listener: SocketListener<ClientState> | null = null;
|
|
18
30
|
#client: Socket<ClientState> | null = null;
|
|
19
31
|
|
|
20
|
-
constructor(host: CodeAgentRpcHost, address: CodeAgentRpcAddress) {
|
|
32
|
+
constructor(host: CodeAgentRpcHost, address: CodeAgentRpcAddress, options: CodeAgentRpcListenerOptions = {}) {
|
|
21
33
|
this.#host = host;
|
|
22
34
|
this.#address = address;
|
|
35
|
+
this.#token =
|
|
36
|
+
options.token !== undefined ? options.token : "unix" in address ? null : CodeAgentRpcListener.newToken();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static newToken() {
|
|
40
|
+
return randomBytes(32).toString("base64url");
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
static parse(spec: string): CodeAgentRpcAddress {
|
|
43
|
+
static parse(spec: string, { anyHost = false }: { anyHost?: boolean } = {}): CodeAgentRpcAddress {
|
|
26
44
|
if (spec.startsWith("unix:")) return { unix: spec.slice("unix:".length) };
|
|
27
45
|
const rest = spec.startsWith("tcp:") ? spec.slice("tcp:".length) : spec;
|
|
28
46
|
const separator = rest.lastIndexOf(":");
|
|
29
|
-
const hostname = separator >= 0 ? rest.slice(0, separator) : "127.0.0.1";
|
|
47
|
+
const hostname = (separator >= 0 ? rest.slice(0, separator) : "") || "127.0.0.1";
|
|
30
48
|
const port = Number(separator >= 0 ? rest.slice(separator + 1) : rest);
|
|
31
49
|
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
32
50
|
throw new Error(`--rpc-listen takes unix:<path> or tcp:[host:]<port>, not "${spec}"`);
|
|
33
|
-
|
|
51
|
+
if (!anyHost && !codeAgentIsLoopback(hostname))
|
|
52
|
+
throw new Error(
|
|
53
|
+
`--rpc-listen ${spec} would be reachable from other machines. Bind 127.0.0.1, or pass --rpc-bind-any to mean it.`,
|
|
54
|
+
);
|
|
55
|
+
return { hostname, port };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get port(): number | null {
|
|
59
|
+
const port = this.#listener && "port" in this.#listener ? this.#listener.port : null;
|
|
60
|
+
return typeof port === "number" ? port : null;
|
|
34
61
|
}
|
|
35
62
|
|
|
36
|
-
get
|
|
37
|
-
return this.#
|
|
63
|
+
get token() {
|
|
64
|
+
return this.#token;
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
listen() {
|
|
@@ -46,10 +73,17 @@ export class CodeAgentRpcListener {
|
|
|
46
73
|
close: (client: Socket<ClientState>) => this.#close(client),
|
|
47
74
|
error: (client: Socket<ClientState>) => this.#close(client),
|
|
48
75
|
};
|
|
49
|
-
this.#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
if ("unix" in this.#address) {
|
|
77
|
+
// Created owner-only, so no other user can connect in the moment before the chmod.
|
|
78
|
+
const umask = process.umask(0o177);
|
|
79
|
+
try {
|
|
80
|
+
this.#listener = Bun.listen<ClientState>({ unix: this.#address.unix, socket });
|
|
81
|
+
} finally {
|
|
82
|
+
process.umask(umask);
|
|
83
|
+
}
|
|
84
|
+
chmodSync(this.#address.unix, 0o600);
|
|
85
|
+
} else
|
|
86
|
+
this.#listener = Bun.listen<ClientState>({ hostname: this.#address.hostname, port: this.#address.port, socket });
|
|
53
87
|
this.#host.start();
|
|
54
88
|
return this;
|
|
55
89
|
}
|
|
@@ -70,16 +104,67 @@ export class CodeAgentRpcListener {
|
|
|
70
104
|
}
|
|
71
105
|
|
|
72
106
|
#open(client: Socket<ClientState>) {
|
|
73
|
-
client.data = { pending: null, decoder: new TextDecoder() };
|
|
107
|
+
client.data = { pending: null, decoder: new TextDecoder(), admitted: false, preface: "", timer: null };
|
|
108
|
+
client.data.timer = setTimeout(() => {
|
|
109
|
+
if (!client.data.admitted) client.end();
|
|
110
|
+
}, CodeAgentRpcListener.prefaceMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#data(client: Socket<ClientState>, data: Buffer) {
|
|
114
|
+
const text = client.data.decoder.decode(data, { stream: true });
|
|
115
|
+
if (client.data.admitted) {
|
|
116
|
+
if (client === this.#client) void this.#host.feed(text);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
client.data.preface += text;
|
|
120
|
+
const preface = client.data.preface.trimStart();
|
|
121
|
+
// A browser or any other protocol speaks first with something other than a JSON frame.
|
|
122
|
+
if (preface && !preface.startsWith("{")) {
|
|
123
|
+
client.end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!this.#token) {
|
|
127
|
+
this.#admit(client);
|
|
128
|
+
return void this.#host.feed(preface);
|
|
129
|
+
}
|
|
130
|
+
const newline = preface.indexOf("\n");
|
|
131
|
+
if (newline < 0) {
|
|
132
|
+
if (preface.length > CodeAgentRpcListener.prefaceLimit) client.end();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!this.#authenticates(preface.slice(0, newline))) {
|
|
136
|
+
client.write(`${JSON.stringify({ type: "reply", id: "auth", ok: false, error: "unauthorized" })}\n`);
|
|
137
|
+
client.end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.#admit(client);
|
|
141
|
+
this.#write(client, `${JSON.stringify({ type: "reply", id: "auth", ok: true })}\n`);
|
|
142
|
+
const rest = preface.slice(newline + 1);
|
|
143
|
+
if (rest) void this.#host.feed(rest);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#admit(client: Socket<ClientState>) {
|
|
147
|
+
if (client.data.timer) clearTimeout(client.data.timer);
|
|
148
|
+
client.data.timer = null;
|
|
149
|
+
client.data.admitted = true;
|
|
150
|
+
client.data.preface = "";
|
|
74
151
|
const previous = this.#client;
|
|
75
152
|
this.#client = client;
|
|
76
153
|
previous?.end();
|
|
77
154
|
this.#host.attach((line) => this.#write(client, line));
|
|
78
155
|
}
|
|
79
156
|
|
|
80
|
-
#
|
|
81
|
-
if (
|
|
82
|
-
|
|
157
|
+
#authenticates(line: string) {
|
|
158
|
+
if (!this.#token) return true;
|
|
159
|
+
let auth: unknown;
|
|
160
|
+
try {
|
|
161
|
+
auth = (JSON.parse(line) as { auth?: unknown }).auth;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
if (typeof auth !== "string") return false;
|
|
166
|
+
const digest = (value: string) => createHash("sha256").update(value).digest();
|
|
167
|
+
return timingSafeEqual(digest(auth), digest(this.#token));
|
|
83
168
|
}
|
|
84
169
|
|
|
85
170
|
//* A socket write may take only part of a frame; the rest waits for `drain`, and frames behind it queue in order.
|
|
@@ -102,6 +187,7 @@ export class CodeAgentRpcListener {
|
|
|
102
187
|
}
|
|
103
188
|
|
|
104
189
|
#close(client: Socket<ClientState>) {
|
|
190
|
+
if (client.data?.timer) clearTimeout(client.data.timer);
|
|
105
191
|
if (client !== this.#client) return;
|
|
106
192
|
this.#client = null;
|
|
107
193
|
this.#host.attach(null);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { McpServerConfig } from "../tools/McpServerConfig";
|
|
15
|
+
import { akanCodePaths } from "./akanCodePaths";
|
|
16
|
+
|
|
17
|
+
export type WorkspaceTrustKind = "mcp" | "extensions";
|
|
18
|
+
|
|
19
|
+
export interface WorkspaceTrustItem {
|
|
20
|
+
kind: WorkspaceTrustKind;
|
|
21
|
+
lines: string[];
|
|
22
|
+
hash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type TrustRecord = Partial<Record<WorkspaceTrustKind, string>>;
|
|
26
|
+
|
|
27
|
+
interface ExtensionWalk {
|
|
28
|
+
root: string;
|
|
29
|
+
files: { name: string; path: string }[];
|
|
30
|
+
bytes: number;
|
|
31
|
+
seen: Set<string>;
|
|
32
|
+
unchecked?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const maxExtensionFiles = 10_000;
|
|
36
|
+
const maxExtensionBytes = 64 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
// A repo's mcp.json starts processes and its extensions/ load as code, so each waits for a yes to its exact content.
|
|
39
|
+
export class WorkspaceTrust {
|
|
40
|
+
static readonly #granted = new Map<string, string>();
|
|
41
|
+
|
|
42
|
+
static items(workspaceRoot: string, kinds: WorkspaceTrustKind[] = ["mcp", "extensions"]) {
|
|
43
|
+
return kinds
|
|
44
|
+
.map((kind) => WorkspaceTrust.#itemOf(workspaceRoot, kind))
|
|
45
|
+
.filter((item): item is WorkspaceTrustItem => !!item);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static pending(workspaceRoot: string, kinds?: WorkspaceTrustKind[]) {
|
|
49
|
+
return WorkspaceTrust.items(workspaceRoot, kinds).filter((item) => !WorkspaceTrust.#approved(workspaceRoot, item));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static isTrusted(workspaceRoot: string, kind: WorkspaceTrustKind) {
|
|
53
|
+
const item = WorkspaceTrust.#itemOf(workspaceRoot, kind);
|
|
54
|
+
return !item || WorkspaceTrust.#approved(workspaceRoot, item);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static approve(workspaceRoot: string, items: WorkspaceTrustItem[], remember: boolean) {
|
|
58
|
+
const key = WorkspaceTrust.#keyOf(workspaceRoot);
|
|
59
|
+
for (const item of items) WorkspaceTrust.#granted.set(`${key}\n${item.kind}`, item.hash);
|
|
60
|
+
const checked = items.filter((item) => item.hash);
|
|
61
|
+
if (!remember || !checked.length) return;
|
|
62
|
+
const all = WorkspaceTrust.#read();
|
|
63
|
+
const record: TrustRecord = { ...all[key] };
|
|
64
|
+
for (const item of checked) record[item.kind] = item.hash;
|
|
65
|
+
WorkspaceTrust.#write({ ...all, [key]: record });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static carry<T>(workspaceRoot: string, kind: WorkspaceTrustKind, edit: () => T): T {
|
|
69
|
+
const before = WorkspaceTrust.#itemOf(workspaceRoot, kind);
|
|
70
|
+
const trusted = !before || WorkspaceTrust.#approved(workspaceRoot, before);
|
|
71
|
+
const remembered = !before || WorkspaceTrust.#read()[WorkspaceTrust.#keyOf(workspaceRoot)]?.[kind] === before.hash;
|
|
72
|
+
const result = edit();
|
|
73
|
+
const after = WorkspaceTrust.#itemOf(workspaceRoot, kind);
|
|
74
|
+
if (trusted && after) WorkspaceTrust.approve(workspaceRoot, [after], remembered);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static #approved(workspaceRoot: string, item: WorkspaceTrustItem) {
|
|
79
|
+
const key = WorkspaceTrust.#keyOf(workspaceRoot);
|
|
80
|
+
return (
|
|
81
|
+
WorkspaceTrust.#granted.get(`${key}\n${item.kind}`) === item.hash ||
|
|
82
|
+
(!!item.hash && WorkspaceTrust.#read()[key]?.[item.kind] === item.hash)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static #itemOf(workspaceRoot: string, kind: WorkspaceTrustKind): WorkspaceTrustItem | undefined {
|
|
87
|
+
return kind === "mcp" ? WorkspaceTrust.#mcp(workspaceRoot) : WorkspaceTrust.#extensions(workspaceRoot);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static #mcp(workspaceRoot: string): WorkspaceTrustItem | undefined {
|
|
91
|
+
const servers = McpServerConfig.read(workspaceRoot, ["workspace"]).sort((a, b) => a.name.localeCompare(b.name));
|
|
92
|
+
if (!servers.length) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
kind: "mcp",
|
|
95
|
+
lines: servers.map((ref) => `${ref.name}: ${McpServerConfig.targetOf(ref)}`),
|
|
96
|
+
hash: WorkspaceTrust.#digest(WorkspaceTrust.#canonical(servers)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//? An item without a hash could not be checked: the trust file never keeps it, so it is asked about at every start.
|
|
101
|
+
static #extensions(workspaceRoot: string): WorkspaceTrustItem | undefined {
|
|
102
|
+
const dir = akanCodePaths.extensionsDir(workspaceRoot);
|
|
103
|
+
let root: string;
|
|
104
|
+
try {
|
|
105
|
+
root = realpathSync(dir);
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const walk: ExtensionWalk = { root, files: [], bytes: 0, seen: new Set() };
|
|
110
|
+
WorkspaceTrust.#walk(walk, dir, "");
|
|
111
|
+
const lines = walk.files.map((file) => file.name);
|
|
112
|
+
if (walk.unchecked) return { kind: "extensions", lines: [walk.unchecked, ...lines], hash: "" };
|
|
113
|
+
if (!walk.files.length) return undefined;
|
|
114
|
+
const hash = createHash("sha256");
|
|
115
|
+
for (const file of walk.files) hash.update(`${file.name}\0${WorkspaceTrust.#digest(readFileSync(file.path))}\n`);
|
|
116
|
+
return { kind: "extensions", lines, hash: hash.digest("hex") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Links are followed while they stay in the folder, since the engine follows them too.
|
|
120
|
+
static #walk(walk: ExtensionWalk, dir: string, prefix: string) {
|
|
121
|
+
let entries: string[];
|
|
122
|
+
try {
|
|
123
|
+
const real = realpathSync(dir);
|
|
124
|
+
if (walk.seen.has(real)) return;
|
|
125
|
+
walk.seen.add(real);
|
|
126
|
+
entries = readdirSync(dir)
|
|
127
|
+
.filter((entry) => entry !== ".git")
|
|
128
|
+
.sort();
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (walk.unchecked) return;
|
|
134
|
+
const full = path.join(dir, entry);
|
|
135
|
+
const name = prefix ? `${prefix}/${entry}` : entry;
|
|
136
|
+
try {
|
|
137
|
+
if (lstatSync(full).isSymbolicLink() && !WorkspaceTrust.#inside(walk.root, realpathSync(full))) {
|
|
138
|
+
walk.unchecked = `${name} links out of the folder: it cannot be checked, so it is asked about at every start`;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const stat = statSync(full);
|
|
142
|
+
if (stat.isDirectory()) WorkspaceTrust.#walk(walk, full, name);
|
|
143
|
+
else if (stat.isFile()) {
|
|
144
|
+
walk.files.push({ name, path: full });
|
|
145
|
+
walk.bytes += stat.size;
|
|
146
|
+
if (walk.files.length > maxExtensionFiles || walk.bytes > maxExtensionBytes)
|
|
147
|
+
walk.unchecked = `more than ${maxExtensionFiles} files or ${maxExtensionBytes >> 20} MiB: too many to check, so asked about at every start`;
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
static #inside(root: string, real: string) {
|
|
154
|
+
const relative = path.relative(root, real);
|
|
155
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static #keyOf(workspaceRoot: string) {
|
|
159
|
+
try {
|
|
160
|
+
return realpathSync(workspaceRoot);
|
|
161
|
+
} catch {
|
|
162
|
+
return path.resolve(workspaceRoot);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static #digest(content: string | Buffer) {
|
|
167
|
+
return createHash("sha256").update(content).digest("hex");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static #canonical(value: unknown): string {
|
|
171
|
+
if (Array.isArray(value)) return `[${value.map((entry) => WorkspaceTrust.#canonical(entry)).join(",")}]`;
|
|
172
|
+
if (value && typeof value === "object")
|
|
173
|
+
return `{${Object.entries(value)
|
|
174
|
+
.filter(([, entry]) => entry !== undefined)
|
|
175
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
176
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${WorkspaceTrust.#canonical(entry)}`)
|
|
177
|
+
.join(",")}}`;
|
|
178
|
+
return JSON.stringify(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
static #read(): Record<string, TrustRecord> {
|
|
182
|
+
const file = akanCodePaths.trustFile();
|
|
183
|
+
if (!existsSync(file)) return {};
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as { workspaces?: Record<string, TrustRecord> };
|
|
186
|
+
return parsed.workspaces ?? {};
|
|
187
|
+
} catch {
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
static #write(workspaces: Record<string, TrustRecord>) {
|
|
193
|
+
akanCodePaths.ensureGlobalDir();
|
|
194
|
+
const file = akanCodePaths.trustFile();
|
|
195
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
196
|
+
writeFileSync(temp, `${JSON.stringify({ workspaces }, null, 2)}\n`, { mode: 0o600 });
|
|
197
|
+
renameSync(temp, file);
|
|
198
|
+
chmodSync(file, 0o600);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { FileSys } from "../../fileSys";
|
|
5
|
+
import { AkanCodeEnv } from "./AkanCodeEnv";
|
|
4
6
|
|
|
5
7
|
// Two candidates: the CLI bundle flattens entry chunks into the package root, where `skills/` is a sibling.
|
|
6
8
|
const builtinSkillsDir = () => {
|
|
@@ -9,7 +11,9 @@ const builtinSkillsDir = () => {
|
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
// One override for the whole set, so a `models.json` and the `auth.json` it names never drift apart.
|
|
12
|
-
const globalDir = () =>
|
|
14
|
+
const globalDir = () => AkanCodeEnv.get("AKAN_CODE_HOME") ?? path.join(homedir(), ".akan", "code");
|
|
15
|
+
|
|
16
|
+
const ensureGlobalDir = () => FileSys.ensurePrivateDir(globalDir());
|
|
13
17
|
|
|
14
18
|
// Credentials live under the home directory, never the repo: a repo is what gets committed, zipped and shared.
|
|
15
19
|
export const akanCodePaths = {
|
|
@@ -24,8 +28,11 @@ export const akanCodePaths = {
|
|
|
24
28
|
globalMcpFile: () => path.join(globalDir(), "mcp.json"),
|
|
25
29
|
globalSkillsDir: () => path.join(globalDir(), "skills"),
|
|
26
30
|
globalDir,
|
|
31
|
+
ensureGlobalDir,
|
|
27
32
|
authFile: () => path.join(globalDir(), "auth.json"),
|
|
28
33
|
/** MCP bearer tokens, beside the provider keys and for the same reason: never in a directory that is shared. */
|
|
29
34
|
mcpAuthFile: () => path.join(globalDir(), "mcpAuth.json"),
|
|
30
35
|
modelsFile: () => path.join(globalDir(), "models.json"),
|
|
36
|
+
trustFile: () => path.join(globalDir(), "trust.json"),
|
|
37
|
+
rpcTokenFile: (port: number) => path.join(globalDir(), "rpc", `${port}.token`),
|
|
31
38
|
} as const;
|