@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.
Files changed (188) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /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
- mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
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: present(akanCodePaths.extensionsDir(options.workspaceRoot)),
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.#deny = (profile.paths.deny ?? []).map((pattern) => new Bun.Glob(pattern));
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 = [...new Set((profile.paths.deny ?? []).map(CodeAgentGate.#fragmentOf).filter(Boolean))];
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 = this.#deniedFragments.find((fragment) => command.includes(fragment));
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.isAbsolute(target) ? target : path.resolve(this.#profile.paths.root, target);
53
- const relative = path.relative(this.#profile.paths.root, absolute);
54
- if (relative.startsWith("..") || path.isAbsolute(relative))
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
- if (this.#deny.some((glob) => glob.match(absolute) || glob.match(relative)))
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 #fragmentOf(pattern: string) {
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
- return trimmed.includes("*") ? "" : trimmed;
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 = process.env.AKAN_CODE_PROXY_URL?.trim() || profile?.network.proxyBaseUrl;
40
+ const baseUrl = AkanCodeEnv.get("AKAN_CODE_PROXY_URL")?.trim() || profile?.network.proxyBaseUrl;
40
41
  if (!baseUrl) return null;
41
- const providers = (process.env.AKAN_CODE_PROXY_PROVIDERS ?? akanCodeDefaultModel.provider)
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: process.env.AKAN_CODE_PROXY_TOKEN_FILE?.trim() || undefined,
49
- token: process.env.AKAN_CODE_PROXY_TOKEN?.trim() || undefined,
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 { existsSync, rmSync } from "node:fs";
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
- // TCP binds 127.0.0.1 unless a host is named, because the wire has no authentication of its own.
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
- return { hostname: hostname || "127.0.0.1", port };
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 port() {
37
- return this.#listener && "port" in this.#listener ? this.#listener.port : null;
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.#listener =
50
- "unix" in this.#address
51
- ? Bun.listen<ClientState>({ unix: this.#address.unix, socket })
52
- : Bun.listen<ClientState>({ hostname: this.#address.hostname, port: this.#address.port, socket });
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
- #data(client: Socket<ClientState>, data: Buffer) {
81
- if (client !== this.#client) return;
82
- void this.#host.feed(client.data.decoder.decode(data, { stream: true }));
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,