@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,26 @@
|
|
|
1
|
+
import { ShellOnlyEnv } from "../../shellOnlyEnv";
|
|
2
|
+
|
|
3
|
+
export const akanCodeGuardedEnv = [
|
|
4
|
+
"AKAN_CODE_HOME",
|
|
5
|
+
"AKAN_CODE_CAN_PROMPT",
|
|
6
|
+
"AKAN_CODE_PROXY_URL",
|
|
7
|
+
"AKAN_CODE_PROXY_TOKEN",
|
|
8
|
+
"AKAN_CODE_PROXY_TOKEN_FILE",
|
|
9
|
+
"AKAN_CODE_PROXY_PROVIDERS",
|
|
10
|
+
"AKAN_CODE_RPC_TOKEN_FILE",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type AkanCodeGuardedEnvKey = (typeof akanCodeGuardedEnv)[number];
|
|
14
|
+
|
|
15
|
+
// Each key moves the credential store, a confirmation or the model traffic.
|
|
16
|
+
export class AkanCodeEnv {
|
|
17
|
+
static readonly #env = new ShellOnlyEnv(akanCodeGuardedEnv);
|
|
18
|
+
|
|
19
|
+
static get(key: AkanCodeGuardedEnvKey): string | undefined {
|
|
20
|
+
return AkanCodeEnv.#env.get(key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static ignored() {
|
|
24
|
+
return AkanCodeEnv.#env.ignored();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -10,6 +10,7 @@ import { akanCodePaths } from "./akanCodePaths";
|
|
|
10
10
|
import { akanSystemPrompt } from "./akanSystemPrompt";
|
|
11
11
|
import type { CodeAgentProxy } from "./CodeAgentProxy";
|
|
12
12
|
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
13
|
+
import { WorkspaceTrust } from "./WorkspaceTrust";
|
|
13
14
|
|
|
14
15
|
export interface AkanCodeServicesOptions {
|
|
15
16
|
workspaceRoot: string;
|
|
@@ -34,7 +35,7 @@ export class AkanCodeServices {
|
|
|
34
35
|
|
|
35
36
|
// The engine locks and rewrites `authPath` itself, so two `akan code` refreshing a token at once cannot clobber it.
|
|
36
37
|
static async runtime(workspaceRoot: string, proxy: CodeAgentProxy | null = null) {
|
|
37
|
-
|
|
38
|
+
akanCodePaths.ensureGlobalDir();
|
|
38
39
|
const runtime = await ModelRuntime.create({
|
|
39
40
|
//* Behind a proxy no real key may reach a request, so credentials live in a throwaway file.
|
|
40
41
|
authPath: proxy
|
|
@@ -88,7 +89,9 @@ export class AkanCodeServices {
|
|
|
88
89
|
: agentsFiles,
|
|
89
90
|
}),
|
|
90
91
|
appendSystemPrompt: [akanSystemPrompt(options.profile)],
|
|
91
|
-
additionalExtensionPaths:
|
|
92
|
+
additionalExtensionPaths: WorkspaceTrust.isTrusted(options.workspaceRoot, "extensions")
|
|
93
|
+
? present(akanCodePaths.extensionsDir(options.workspaceRoot))
|
|
94
|
+
: [],
|
|
92
95
|
additionalSkillPaths: AkanCodeServices.#skillPaths(options.profile, options.workspaceRoot),
|
|
93
96
|
extensionFactories: options.extensions,
|
|
94
97
|
};
|
|
@@ -114,6 +114,10 @@ export class CodeAgent {
|
|
|
114
114
|
profile: options.profile,
|
|
115
115
|
apps: options.apps ?? [],
|
|
116
116
|
canSeeImages: akanCodeModelSupportsImages(model),
|
|
117
|
+
model: () => {
|
|
118
|
+
const current = agent.#session?.model;
|
|
119
|
+
return current ? { provider: current.provider, id: current.id } : undefined;
|
|
120
|
+
},
|
|
117
121
|
deepseek: await DeepSeekWeb.fromRuntime(modelRuntime, !!proxy),
|
|
118
122
|
depth: options.depth ?? 0,
|
|
119
123
|
...(options.subagentSpend ? { subagentSpend: options.subagentSpend } : {}),
|
|
@@ -531,6 +535,13 @@ export class CodeAgent {
|
|
|
531
535
|
const verdict = this.#gate.verdict(event.toolName, event.input);
|
|
532
536
|
if (verdict.block) return this.#refuse(event.toolCallId, event.toolName, event.input, verdict.block);
|
|
533
537
|
if (!verdict.approval) return undefined;
|
|
538
|
+
if (verdict.strict && !this.#profile.ui.canPrompt)
|
|
539
|
+
return this.#refuse(
|
|
540
|
+
event.toolCallId,
|
|
541
|
+
event.toolName,
|
|
542
|
+
event.input,
|
|
543
|
+
"This reaches a host outside the profile's list, which needs the user's approval, and nobody can be asked in this session. Use a listed host, or say what you need fetched.",
|
|
544
|
+
);
|
|
534
545
|
const approved = await this.#requestApproval(event.toolCallId, event.toolName, event.input, verdict.approval);
|
|
535
546
|
if (approved) return undefined;
|
|
536
547
|
return this.#refuse(event.toolCallId, event.toolName, event.input, "The user declined this action.");
|
|
@@ -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);
|
|
@@ -2,11 +2,14 @@ import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-ag
|
|
|
2
2
|
import { type CodeAgentProfile, type CodeAgentSubagent, codeAgentReadOnlyBuiltins } from "akanjs/common";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import type { Workspace } from "../../commandDecorators";
|
|
5
|
+
import type { CodeAgentModelRef } from "./akanCodeModel";
|
|
5
6
|
|
|
6
7
|
export interface SubagentPoolOptions {
|
|
7
8
|
workspace: Workspace;
|
|
8
9
|
cwd: string;
|
|
9
10
|
parent: CodeAgentProfile;
|
|
11
|
+
/** The parent's model, read when a task starts: a child runs on the model the parent runs on now. */
|
|
12
|
+
model?: () => CodeAgentModelRef | undefined;
|
|
10
13
|
depth: number;
|
|
11
14
|
/** What the tree above this pool has spent and has running; a root pool starts its own. */
|
|
12
15
|
spend?: SubagentSpend;
|
|
@@ -164,10 +167,12 @@ export class SubagentPool {
|
|
|
164
167
|
const { CodeAgent } = await import("./CodeAgent");
|
|
165
168
|
const budget = this.#options.parent.tools.subagent;
|
|
166
169
|
const child = SubagentPool.childOf(this.#options.parent, kind);
|
|
170
|
+
const model = this.#options.model?.();
|
|
167
171
|
const agent = await CodeAgent.create({
|
|
168
172
|
workspace: this.#options.workspace,
|
|
169
173
|
cwd: this.#options.cwd,
|
|
170
174
|
profile: child,
|
|
175
|
+
...(model ? { model } : {}),
|
|
171
176
|
apps: [],
|
|
172
177
|
depth: this.#options.depth + 1,
|
|
173
178
|
subagentSpend: this.#spend,
|