@akanjs/devkit 3.1.1-rc.9 → 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/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- 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 +11 -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/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- 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/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- 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 +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- 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/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -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 +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -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/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -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;
|
|
@@ -13,7 +13,7 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
13
13
|
];
|
|
14
14
|
if (profile.tools.builtin.includes("write"))
|
|
15
15
|
lines.push(
|
|
16
|
-
"- After editing, call `akan_verify
|
|
16
|
+
"- After editing, call `akan_verify` once. It runs sync, lint, typecheck, a fast build and the SSR check over what the working tree changed; never run `akan lint`, `akan typecheck` or `akan build` separately. A turn that edits and does not verify is not finished.",
|
|
17
17
|
);
|
|
18
18
|
else
|
|
19
19
|
lines.push(
|
|
@@ -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
|
"",
|
|
@@ -6,7 +6,7 @@ import { type CodeAgentEvent, type CodeAgentProfile, codeAgentPresets } from "ak
|
|
|
6
6
|
import { WorkspaceExecutor } from "../../executors";
|
|
7
7
|
import { isolateEnv, tempDirs } from "../../testHelpers";
|
|
8
8
|
import { AkanCodePlugins } from "../tools/AkanCodePlugins";
|
|
9
|
-
import { akanCodeDefaultModel } from "./akanCodeModel";
|
|
9
|
+
import { akanCodeDefaultModel, type CodeAgentModelRef } from "./akanCodeModel";
|
|
10
10
|
import { akanCodePaths } from "./akanCodePaths";
|
|
11
11
|
import { CodeAgent, type CodeAgentOptions } from "./CodeAgent";
|
|
12
12
|
import { CodeAgentSuspended } from "./CodeAgentSuspended";
|
|
@@ -37,11 +37,14 @@ const usageChunk = (promptTokens: number) =>
|
|
|
37
37
|
let llm: ReturnType<typeof Bun.serve>;
|
|
38
38
|
let llmCalls = 0;
|
|
39
39
|
let reportedTokens = 0;
|
|
40
|
+
const requestedModels: string[] = [];
|
|
40
41
|
beforeAll(() => {
|
|
41
42
|
llm = Bun.serve({
|
|
42
43
|
port: 0,
|
|
43
|
-
fetch: () => {
|
|
44
|
+
fetch: async (request) => {
|
|
44
45
|
llmCalls += 1;
|
|
46
|
+
const body = (await request.json().catch(() => ({}))) as { model?: string };
|
|
47
|
+
requestedModels.push(body.model ?? "");
|
|
45
48
|
const usage = reportedTokens ? usageChunk(reportedTokens) : "";
|
|
46
49
|
return new Response(
|
|
47
50
|
`${chunk({ role: "assistant", content: "ok" })}${chunk({}, "stop")}${usage}data: [DONE]\n\n`,
|
|
@@ -160,6 +163,42 @@ describe("CodeAgent", () => {
|
|
|
160
163
|
spy.mockRestore();
|
|
161
164
|
}
|
|
162
165
|
});
|
|
166
|
+
|
|
167
|
+
test("a task child sends its requests on the parent's model, and on the one the parent switches to", async () => {
|
|
168
|
+
const root = await makeTempRoot();
|
|
169
|
+
const build = AkanCodePlugins.build;
|
|
170
|
+
let parentModel: (() => CodeAgentModelRef | undefined) | undefined;
|
|
171
|
+
const spy = spyOn(AkanCodePlugins, "build").mockImplementation(async (options) => {
|
|
172
|
+
parentModel = options.model;
|
|
173
|
+
return await build.call(AkanCodePlugins, options);
|
|
174
|
+
});
|
|
175
|
+
const agent = await createAgent(root, { model: { provider: "deepseek", id: "deepseek-v4-pro" } });
|
|
176
|
+
spy.mockRestore();
|
|
177
|
+
const profile = quietProfile(root);
|
|
178
|
+
const pool = SubagentPool.extensionFor({
|
|
179
|
+
workspace: new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" }),
|
|
180
|
+
cwd: root,
|
|
181
|
+
parent: { ...profile, tools: { ...profile.tools, subagent: { maxDepth: 2, maxConcurrent: 3, budget: 100_000 } } },
|
|
182
|
+
...(parentModel ? { model: parentModel } : {}),
|
|
183
|
+
depth: 0,
|
|
184
|
+
});
|
|
185
|
+
let execute: ((id: string, params: never) => Promise<unknown>) | undefined;
|
|
186
|
+
if (pool && "factory" in pool)
|
|
187
|
+
pool.factory({
|
|
188
|
+
registerTool: (tool: { execute: typeof execute }) => {
|
|
189
|
+
execute = tool.execute;
|
|
190
|
+
},
|
|
191
|
+
} as never);
|
|
192
|
+
try {
|
|
193
|
+
requestedModels.length = 0;
|
|
194
|
+
await execute?.("c1", { description: "d", prompt: "p" } as never);
|
|
195
|
+
await agent.setModel(akanCodeDefaultModel);
|
|
196
|
+
await execute?.("c2", { description: "d", prompt: "p" } as never);
|
|
197
|
+
expect(requestedModels).toEqual(["deepseek-v4-pro", akanCodeDefaultModel.id]);
|
|
198
|
+
} finally {
|
|
199
|
+
agent.dispose();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
163
202
|
});
|
|
164
203
|
|
|
165
204
|
describe("a sub-agent its parent stops before the sub-agent's run is live", () => {
|
|
@@ -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
|
+
});
|