@akanjs/devkit 3.1.1 → 3.1.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/akanConfig/akanConfig.test.ts +75 -0
  2. package/akanConfig/akanConfig.ts +11 -1
  3. package/akanMcpContract.test.ts +28 -1
  4. package/akanMcpContract.ts +51 -3
  5. package/applicationBuildRunner.test.ts +36 -0
  6. package/applicationBuildRunner.ts +4 -3
  7. package/artifact/implicitRootLayout.ts +4 -2
  8. package/cloud/globalConfig.test.ts +34 -0
  9. package/cloud/globalConfig.ts +2 -2
  10. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  11. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  12. package/codeAgent/agent/CodeAgent.ts +7 -0
  13. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  14. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  15. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  16. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  17. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  18. package/codeAgent/agent/akanCodePaths.ts +8 -1
  19. package/codeAgent/agent/akanSystemPrompt.ts +4 -0
  20. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  21. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  22. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  23. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  24. package/codeAgent/agent/stringArg.ts +1 -1
  25. package/codeAgent/skills/akan-agent/SKILL.md +8 -1
  26. package/codeAgent/skills/akan-code/SKILL.md +37 -11
  27. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  28. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  29. package/codeAgent/skills/akan-query/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
  31. package/codeAgent/tools/McpServerConfig.ts +6 -4
  32. package/codeAgent/tools/McpSignIn.ts +7 -7
  33. package/codeAgent/tools/McpTokenStore.ts +63 -16
  34. package/codeAgent/tools/McpToolPack.ts +8 -1
  35. package/codeAgent/tools/WebToolPack.ts +50 -6
  36. package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
  37. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  38. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  39. package/commandDecorators/command.ts +4 -1
  40. package/createTunnel.test.ts +200 -2
  41. package/createTunnel.ts +80 -6
  42. package/envSeed.test.ts +90 -0
  43. package/envSeed.ts +68 -0
  44. package/executors.test.ts +19 -1
  45. package/executors.ts +41 -2
  46. package/fileSys.test.ts +30 -2
  47. package/fileSys.ts +14 -0
  48. package/formSetterScanner.test.ts +44 -1
  49. package/formSetterScanner.ts +53 -8
  50. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  51. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  52. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  53. package/frontendBuild/optionalPeers.test.ts +132 -0
  54. package/integration/ssrMemoryProbe.ts +6 -0
  55. package/lint/no-unguarded-endpoint.grit +5 -5
  56. package/maintainabilityReport.ts +1 -1
  57. package/mobile/desktopServerStage.test.ts +1 -0
  58. package/mobile/desktopServerStage.ts +3 -1
  59. package/mobile/nativeApp.test.ts +20 -5
  60. package/mobile/nativeApp.ts +4 -1
  61. package/mobile/nativeConfig.test.ts +209 -5
  62. package/mobile/nativeConfig.ts +127 -6
  63. package/mobile/nativePublicEnv.test.ts +2 -0
  64. package/mobile/nativePublicEnv.ts +13 -0
  65. package/package.json +3 -2
  66. package/shellOnlyEnv.test.ts +65 -0
  67. package/shellOnlyEnv.ts +54 -0
  68. package/signalMutationIndex.ts +22 -81
  69. package/sshHostKeys.ts +59 -0
  70. package/storeSource.ts +9 -8
  71. package/subspace.test.ts +10 -0
  72. package/subspaceClone.ts +3 -1
  73. package/subspaceConfig.ts +6 -0
  74. package/transforms/barrelImportsPlugin.ts +10 -4
  75. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  76. package/transforms/mcpAppPageTrimmer.ts +5 -20
  77. package/transforms/pureLazyMarker.ts +1 -1
  78. package/workflow/mutationScaffold.ts +35 -21
  79. package/workflow/plan.ts +28 -0
  80. package/workflow/sourceAst.ts +10 -48
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { AppExecutor, WorkspaceExecutor } from "../executors";
7
+ import { NativeConfig } from "../mobile/nativeConfig";
7
8
  import type { PackageJson } from "../types";
8
9
  import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
9
10
  import type { LibConfigInput } from "./types";
@@ -165,6 +166,23 @@ describe("AkanAppConfig", () => {
165
166
  expect(unlocked).not.toContain("bun.lock");
166
167
  });
167
168
 
169
+ test("writes a Dockerfile for a local build that refuses docker build without an explicit build arg", () => {
170
+ const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
171
+ const local = config.dockerfileFor("local");
172
+ const guard = local.split("\n").find((line) => line.includes("AKAN_ALLOW_LOCAL_IMAGE") && line.startsWith("RUN "));
173
+ if (!guard) throw new Error("expected the local image guard");
174
+ const run = (allow?: string) =>
175
+ Bun.spawnSync(["sh", "-c", guard.slice("RUN ".length)], {
176
+ env: { PATH: process.env.PATH ?? "", ...(allow ? { AKAN_ALLOW_LOCAL_IMAGE: allow } : {}) },
177
+ });
178
+
179
+ expect(local).toContain(`FROM ${pinnedBunImage}\nARG AKAN_ALLOW_LOCAL_IMAGE\nRUN if`);
180
+ expect(config.dockerfileFor("main")).not.toContain("AKAN_ALLOW_LOCAL_IMAGE");
181
+ expect(run().exitCode).toBe(1);
182
+ expect(run().stderr.toString()).toContain("--build-arg AKAN_ALLOW_LOCAL_IMAGE=1");
183
+ expect(run("1").exitCode).toBe(0);
184
+ });
185
+
168
186
  test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
169
187
  const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
170
188
 
@@ -220,6 +238,17 @@ describe("AkanAppConfig", () => {
220
238
  expect(config.dockerfile).toContain(`COPY . .\n${envBlock}\nCMD ["bun","main.js"]`);
221
239
  });
222
240
 
241
+ test('publishes localePrefix "as-needed" to the bundles and the image, and leaves the default out of the image', () => {
242
+ const asNeeded = new AkanAppConfig(app, [], packageJson, { i18n: { localePrefix: "as-needed" } }, baseDevEnv);
243
+ expect(process.env.AKAN_PUBLIC_LOCALE_PREFIX).toBe("as-needed");
244
+ expect(asNeeded.getProductionEnv().AKAN_PUBLIC_LOCALE_PREFIX).toBe("as-needed");
245
+ expect(asNeeded.dockerfile).toContain("ENV AKAN_PUBLIC_LOCALE_PREFIX=as-needed");
246
+
247
+ const always = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
248
+ expect(process.env.AKAN_PUBLIC_LOCALE_PREFIX).toBe("always");
249
+ expect(always.getProductionEnv()).not.toHaveProperty("AKAN_PUBLIC_LOCALE_PREFIX");
250
+ });
251
+
223
252
  test("writes the Dockerfile instructions it wrote before its env came from getProductionEnv", () => {
224
253
  //? 9adfb95c's output for this config, less the blank lines its empty interpolations left and its floating tag.
225
254
  const before = [
@@ -679,6 +708,52 @@ describe("AkanAppConfig", () => {
679
708
  ).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKeys.");
680
709
  });
681
710
 
711
+ test("takes the page policy, the opener's links and the release checks from akan.config.ts into the native build", () => {
712
+ const updates = { url: "https://releases.example.com/portal", publicKey: "key=", expiresIn: 86_400 };
713
+ const native = new AkanAppConfig(
714
+ app,
715
+ [],
716
+ packageJson,
717
+ {
718
+ native: {
719
+ csp: { "connect-src": ["https://upload.example.com"] },
720
+ openUrls: ["https://*.example.com/*"],
721
+ updates,
722
+ targets: {
723
+ default: {},
724
+ kiosk: { csp: false, openUrls: ["*"], updates: { windowsSigners: ["AB12"] } },
725
+ },
726
+ },
727
+ },
728
+ baseDevEnv,
729
+ ).native;
730
+ const build = (name: string) =>
731
+ NativeConfig.build({
732
+ appPath: "/repo/apps/portal",
733
+ target: native.targets[name] as NonNullable<(typeof native.targets)[string]>,
734
+ webDir: "/web",
735
+ contributions: [],
736
+ locales: ["en"],
737
+ platform: "windows",
738
+ publicEnv: { AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "example.com" },
739
+ }).config;
740
+ const opener = (config: ReturnType<typeof build>) =>
741
+ config.capabilities?.[0]?.permissions.find((permission) => JSON.stringify(permission).includes("opener"));
742
+
743
+ const store = build("default");
744
+ expect(store.security?.csp).toMatchObject({
745
+ "connect-src": expect.arrayContaining(["https://upload.example.com"]),
746
+ });
747
+ expect(opener(store)).toEqual({ identifier: "opener:default", allow: [{ url: "https://*.example.com/*" }] });
748
+ expect(store.updates).toMatchObject({ expiresIn: 86_400 });
749
+ expect(store.updates).not.toHaveProperty("windowsSigners");
750
+
751
+ const kiosk = build("kiosk");
752
+ expect(kiosk.security).toBeUndefined();
753
+ expect(opener(kiosk)).toBe("opener:default");
754
+ expect(kiosk.updates).toMatchObject({ expiresIn: 86_400, windowsSigners: ["AB12"] });
755
+ });
756
+
682
757
  test("refuses updates holding both publicKey and publicKeys, a target's own key beside the section's list included", () => {
683
758
  const url = "https://releases.example.com/portal";
684
759
  const resolve = (native: object) =>
@@ -227,6 +227,7 @@ export class AkanAppConfig implements AppConfigResult {
227
227
  this.i18n = resolveAkanI18nConfig(config?.i18n);
228
228
  process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
229
229
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
230
+ process.env.AKAN_PUBLIC_LOCALE_PREFIX = this.i18n.localePrefix ?? "always";
230
231
  this.api = {
231
232
  prefix: normalizeRoutePrefix(config?.api?.prefix) ?? "/api",
232
233
  websocketPrefix: normalizeRoutePrefix(config?.api?.websocketPrefix) ?? "/ws",
@@ -468,6 +469,8 @@ export class AkanAppConfig implements AppConfigResult {
468
469
  "deepLinks",
469
470
  "updates",
470
471
  "devtools",
472
+ "csp",
473
+ "openUrls",
471
474
  "ios",
472
475
  "android",
473
476
  "desktop",
@@ -623,6 +626,7 @@ export class AkanAppConfig implements AppConfigResult {
623
626
  ...(this.basePaths.size ? { AKAN_PUBLIC_BASE_PATHS: [...this.basePaths].join(",") } : {}),
624
627
  AKAN_PUBLIC_DEFAULT_LOCALE: this.i18n.defaultLocale,
625
628
  AKAN_PUBLIC_LOCALES: this.i18n.locales.join(","),
629
+ ...(this.i18n.localePrefix === "as-needed" ? { AKAN_PUBLIC_LOCALE_PREFIX: this.i18n.localePrefix } : {}),
626
630
  AKAN_PUBLIC_API_PREFIX: this.api.prefix,
627
631
  AKAN_PUBLIC_WS_PREFIX: this.api.websocketPrefix,
628
632
  AKAN_PUBLIC_OPERATION_MODE: "cloud",
@@ -634,6 +638,11 @@ export class AkanAppConfig implements AppConfigResult {
634
638
  ...(this.web.csr ? {} : { AKAN_CSR: "false" }),
635
639
  };
636
640
  }
641
+ //? An image runs where the build cannot see, and AKAN_PUBLIC_ENV=local serves the dev routes to every caller there.
642
+ static readonly localImageGuard = [
643
+ "ARG AKAN_ALLOW_LOCAL_IMAGE",
644
+ `RUN if [ "$AKAN_ALLOW_LOCAL_IMAGE" != "1" ]; then echo "This build ran with AKAN_PUBLIC_ENV=local, which serves the dev routes to every caller. Build with the environment it deploys to, or pass --build-arg AKAN_ALLOW_LOCAL_IMAGE=1." >&2; exit 1; fi`,
645
+ ].join("\n");
637
646
  dockerfileFor(environment: string, { lockfile = true }: { lockfile?: boolean } = {}): string {
638
647
  return this.#makeDockerfile(environment, lockfile);
639
648
  }
@@ -643,8 +652,9 @@ export class AkanAppConfig implements AppConfigResult {
643
652
  const preRunScripts = this.#getDockerRunScripts(preRuns);
644
653
  const postRunScripts = this.#getDockerRunScripts(postRuns);
645
654
  const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
655
+ const isLocal = (environment ?? this.baseDevEnv.env) === "local";
646
656
  const envLines = Object.entries(this.getProductionEnv(environment)).map(([key, value]) => `ENV ${key}=${value}`);
647
- return `${imageScript}
657
+ return `${imageScript}${isLocal ? `\n${AkanAppConfig.localImageGuard}` : ""}
648
658
  RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
649
659
  RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
650
660
  ARG TARGETARCH
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { createAkanValidationContract } from "./akanMcpContract";
2
+ import { mkdir, symlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createAkanValidationContract, workflowArtifactPath } from "./akanMcpContract";
5
+ import { createTempApp, tempRoots } from "./testHelpers";
6
+
7
+ const track = tempRoots();
3
8
 
4
9
  describe("createAkanValidationContract", () => {
5
10
  test("exposes reference-first tooling rollout gate semantics", () => {
@@ -35,3 +40,25 @@ describe("createAkanValidationContract", () => {
35
40
  );
36
41
  });
37
42
  });
43
+
44
+ describe("workflowArtifactPath", () => {
45
+ test("keeps the plans and reports an MCP call names inside the workspace's workflow folder", async () => {
46
+ const { root, workspace } = track(await createTempApp("demo"));
47
+ const workflows = path.join(root, ".akan/workflows");
48
+ await mkdir(path.join(workflows, "plans"), { recursive: true });
49
+ await symlink(path.join(root, "package.json"), path.join(workflows, "plans/linked.json"));
50
+
51
+ expect(workflowArtifactPath(workspace, ".akan/workflows/plans/a.json")).toBe(path.join(workflows, "plans/a.json"));
52
+ expect(workflowArtifactPath(workspace, `${workflows}/runs/b.json`)).toBe(path.join(workflows, "runs/b.json"));
53
+ expect(workflowArtifactPath(workspace, "add-field-123")).toBe(path.join(workflows, "runs/add-field-123.json"));
54
+ for (const outside of [
55
+ "package.json",
56
+ `${root}/package.json`,
57
+ ".akan/workflows/../../package.json",
58
+ ".akan/workflows",
59
+ ".akan/workflows/plans/linked.json",
60
+ "/tmp/plan.json",
61
+ ])
62
+ expect(() => workflowArtifactPath(workspace, outside)).toThrow("MCP workflow files live under .akan/workflows/");
63
+ });
64
+ });
@@ -1,3 +1,4 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  import { isRecord } from "akanjs/common";
3
4
  import { AkanContextAnalyzer, type AkanDiagnostic, type AkanModuleContext } from "./akanContext";
@@ -127,8 +128,55 @@ const isInspectAkanContextRequestType = (value: unknown): value is InspectAkanCo
127
128
 
128
129
  export const parseJsonOutput = (output: string) => JSON.parse(output) as unknown;
129
130
 
130
- export const workspacePath = (workspace: Workspace, filePath: string) =>
131
- path.isAbsolute(filePath) ? filePath : path.join(workspace.workspaceRoot, filePath);
131
+ const workflowArtifactDir = ".akan/workflows";
132
+ const workflowRunIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
133
+
134
+ const isInside = (base: string, target: string) => {
135
+ const relative = path.relative(base, target);
136
+ return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
137
+ };
138
+
139
+ const hasEntry = (filePath: string) => {
140
+ try {
141
+ lstatSync(filePath);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ };
147
+
148
+ const realPathOfNearest = (filePath: string) => {
149
+ const rest: string[] = [];
150
+ let current = filePath;
151
+ while (!hasEntry(current) && path.dirname(current) !== current) {
152
+ rest.unshift(path.basename(current));
153
+ current = path.dirname(current);
154
+ }
155
+ return path.join(realpathSync(current), ...rest);
156
+ };
157
+
158
+ const staysInside = (workspaceRoot: string, root: string, resolved: string) => {
159
+ try {
160
+ const realRoot = realPathOfNearest(root);
161
+ return isInside(realPathOfNearest(workspaceRoot), realRoot) && isInside(realRoot, realPathOfNearest(resolved));
162
+ } catch {
163
+ return false;
164
+ }
165
+ };
166
+
167
+ //? An MCP caller acts on text it was shown, so the plans and reports it names stay in the workspace's workflow folder.
168
+ export const workflowArtifactPath = (workspace: Workspace, filePath: string) => {
169
+ const root = path.resolve(workspace.workspaceRoot, workflowArtifactDir);
170
+ const resolved =
171
+ workflowRunIdPattern.test(filePath) && !filePath.endsWith(".json")
172
+ ? path.join(root, "runs", `${filePath}.json`)
173
+ : path.resolve(workspace.workspaceRoot, filePath);
174
+ if (!isInside(root, resolved) || !staysInside(workspace.workspaceRoot, root, resolved))
175
+ throw new Error(
176
+ `MCP workflow files live under ${workflowArtifactDir}/ of the workspace, not ${JSON.stringify(filePath)}.`,
177
+ );
178
+ return resolved;
179
+ };
132
180
 
133
181
  export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanInputs) => {
134
182
  const slug = [
@@ -490,7 +538,7 @@ export const inspectAkanContext = async (
490
538
  if (props.request.type === "workflowDiagnostics") {
491
539
  const doctor = await AkanContextAnalyzer.doctor(workspace, {
492
540
  strict: true,
493
- runIdOrPlan: workspacePath(workspace, props.request.runIdOrPlan),
541
+ runIdOrPlan: workflowArtifactPath(workspace, props.request.runIdOrPlan),
494
542
  });
495
543
  const toWorkflowDiagnostic = (
496
544
  { severity, code, message, scope }: AkanDiagnostic,
@@ -84,6 +84,42 @@ console.info(new SampleService().name);
84
84
  }
85
85
  });
86
86
 
87
+ test("writes beside main.js the env its Dockerfile sets, so a host without the image runs the build as is", async () => {
88
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
89
+ const config = new AkanAppConfig(
90
+ { name: "portal" } as never,
91
+ [],
92
+ { name: "repo", version: "1.0.0", description: "repo" },
93
+ {},
94
+ { repoName: "akanjs", serveDomain: "akanjs.com", env: "local", portOffset: 0, workspaceRoot: root },
95
+ );
96
+ const app = {
97
+ getConfig: async () => config,
98
+ dist: new Executor("dist", root),
99
+ workspace: { workspaceRoot: root },
100
+ logger: { warn: () => undefined },
101
+ } as unknown as App;
102
+ const builtEnv = async () => JSON.parse(await readFile(path.join(root, "akan.env.json"), "utf8")) as object;
103
+ const dockerfileEnv = async () =>
104
+ Object.fromEntries(
105
+ (await readFile(path.join(root, "Dockerfile"), "utf8"))
106
+ .split("\n")
107
+ .filter((line) => line.startsWith("ENV "))
108
+ .map((line) => line.slice("ENV ".length).split(/=(.*)/s).slice(0, 2)),
109
+ );
110
+ try {
111
+ await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
112
+ expect(await builtEnv()).toEqual(config.getProductionEnv("debug"));
113
+ expect(await builtEnv()).toEqual(await dockerfileEnv());
114
+ expect(await builtEnv()).toMatchObject({ AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_ENV: "debug" });
115
+ await new ApplicationBuildRunner(app).buildAppMeta();
116
+ expect(await builtEnv()).toMatchObject({ AKAN_PUBLIC_ENV: "local" });
117
+ expect(await builtEnv()).toEqual(await dockerfileEnv());
118
+ } finally {
119
+ await rm(root, { recursive: true, force: true });
120
+ }
121
+ });
122
+
87
123
  test("writes the pruned lockfile beside package.json, or warns and leaves the image's install unlocked", async () => {
88
124
  const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
89
125
  const config = new AkanAppConfig(
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { toError } from "akanjs/common";
3
+ import { BUILD_ENV_FILE } from "akanjs/server/lifecycle/buildEnv";
3
4
  import type { BunPlugin } from "bun";
4
5
  import { ProductionLockfile } from "./akanConfig/productionLockfile";
5
6
  import type {
@@ -240,13 +241,13 @@ export class ApplicationBuildRunner {
240
241
 
241
242
  async buildAppMeta() {
242
243
  const akanConfig = await this.#app.getConfig();
244
+ const environment = this.#environment ?? akanConfig.baseDevEnv.env;
243
245
  const packageJson = akanConfig.getProductionPackageJson();
244
246
  const lockfile = await this.#pruneLockfile(packageJson);
245
- const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env, {
246
- lockfile: lockfile !== null,
247
- });
247
+ const dockerfile = akanConfig.dockerfileFor(environment, { lockfile: lockfile !== null });
248
248
  await Promise.all([
249
249
  this.#app.dist.writeJson("package.json", packageJson),
250
+ this.#app.dist.writeJson(BUILD_ENV_FILE, akanConfig.getProductionEnv(environment)),
250
251
  ...(lockfile !== null
251
252
  ? [this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/${ProductionLockfile.fileName}`, lockfile)]
252
253
  : []),
@@ -33,7 +33,7 @@ async function writeGeneratedOverridesLayoutFile(opts: {
33
33
  const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
34
34
  const absPath = path.join(path.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
35
35
  const source = `"use client";
36
- import { UiOverrideProvider } from "akanjs/ui";
36
+ import { UiOverrideProvider } from "akanjs/ui/UiOverride/UiOverride.tsx";
37
37
  import { createElement, type ReactNode } from "react";
38
38
  import value from ${JSON.stringify(importSpecifier(absPath, opts.userAbsPath))};
39
39
 
@@ -213,11 +213,13 @@ ${guardExport}
213
213
 
214
214
  ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
215
215
  ${layoutBinding} return `;
216
+ //? System from its file, not the barrel: a dev registry compiles every module the barrel names, and with them the
217
+ //? optional peers (react-icons) an app drawing none of those components never installs.
216
218
  const source = opts.includeSystemProvider
217
219
  ? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
218
220
  import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
219
221
  import type { ReactNode } from "react";
220
- import { System } from "akanjs/ui";
222
+ import { System } from "akanjs/ui/System/index.tsx";
221
223
  import type { McpAppRootProps } from "akanjs/webkit";
222
224
  import { env } from "@apps/${opts.appName}/env/env.client";
223
225
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
@@ -1,6 +1,24 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { mkdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tempDirs } from "../testHelpers";
2
5
  import { GlobalConfig } from "./globalConfig";
3
6
 
7
+ const tempHome = tempDirs("akan-global-config-");
8
+ //* A child process, so `~/.akan` resolves under the temp HOME and never the person's own.
9
+ const writeConfigUnder = async (home: string) => {
10
+ const proc = Bun.spawn(
11
+ [
12
+ "bun",
13
+ "-e",
14
+ `await (await import(${JSON.stringify(path.join(import.meta.dir, "globalConfig.ts"))})).GlobalConfig.setTestTargets({});`,
15
+ ],
16
+ { env: { ...process.env, HOME: home, USERPROFILE: home }, stdout: "pipe", stderr: "pipe" },
17
+ );
18
+ expect(await proc.exited).toBe(0);
19
+ };
20
+ const modeOf = async (target: string) => (await stat(target)).mode & 0o777;
21
+
4
22
  const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
5
23
 
6
24
  describe("GlobalConfig", () => {
@@ -14,4 +32,20 @@ describe("GlobalConfig", () => {
14
32
  expect(await GlobalConfig.hostFor(workspace(null), "http://localhost:8283/")).toBe("http://localhost:8283");
15
33
  expect(await GlobalConfig.hostFor(workspace(null))).toBe(GlobalConfig.akanCloudHost);
16
34
  });
35
+
36
+ test.skipIf(process.platform === "win32")(
37
+ "keeps ~/.akan to its owner: a new one is made 0700 and a looser one it owns is tightened",
38
+ async () => {
39
+ const fresh = await tempHome();
40
+ await writeConfigUnder(fresh);
41
+ expect(await modeOf(path.join(fresh, ".akan"))).toBe(0o700);
42
+ expect(await modeOf(path.join(fresh, ".akan", "config.json"))).toBe(0o600);
43
+
44
+ const loose = await tempHome();
45
+ await mkdir(path.join(loose, ".akan"), { mode: 0o755 });
46
+ await writeConfigUnder(loose);
47
+ expect(await modeOf(path.join(loose, ".akan"))).toBe(0o700);
48
+ },
49
+ 20_000,
50
+ );
17
51
  });
@@ -1,4 +1,4 @@
1
- import { mkdir, rm, writeFile } from "node:fs/promises";
1
+ import { rm, writeFile } from "node:fs/promises";
2
2
  import { trimTrailingSlashes } from "akanjs/common";
3
3
  import dayjs from "dayjs";
4
4
  import { FileSys } from "../fileSys";
@@ -36,7 +36,7 @@ export class GlobalConfig {
36
36
  // Holds the cloud jwt and refresh token, so owner-only 0600: written to a 0600 sibling and renamed over the file,
37
37
  // which also replaces a world-readable one and never shows another CLI process a half-written file.
38
38
  static async #setAkanGlobalConfig(akanConfig: AkanGlobalConfig) {
39
- await mkdir(basePath, { recursive: true, mode: 0o700 });
39
+ FileSys.ensurePrivateDir(basePath);
40
40
  const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
41
41
  try {
42
42
  await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
@@ -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
  };
@@ -535,6 +535,13 @@ export class CodeAgent {
535
535
  const verdict = this.#gate.verdict(event.toolName, event.input);
536
536
  if (verdict.block) return this.#refuse(event.toolCallId, event.toolName, event.input, verdict.block);
537
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
+ );
538
545
  const approved = await this.#requestApproval(event.toolCallId, event.toolName, event.input, verdict.approval);
539
546
  if (approved) return undefined;
540
547
  return this.#refuse(event.toolCallId, event.toolName, event.input, "The user declined this action.");