@openpalm/lib 0.11.0-beta.8 → 0.11.0-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 (63) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. package/src/control-plane/registry-components.test.ts +0 -391
package/README.md CHANGED
@@ -21,7 +21,7 @@ Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
21
21
  ## Important context
22
22
 
23
23
  - Some filenames still use legacy names like `staging`; those modules now support the direct-write compose model
24
- - `config/` is user-owned, `config/stack/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
24
+ - `config/` is user-owned, `knowledge/env/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
25
25
  - New reusable control-plane logic belongs here, not duplicated in consumers
26
26
 
27
27
  ## Main module areas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-beta.8",
3
+ "version": "0.11.0-rc.1",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ HOST_SOURCE_NAME,
7
+ addHostStashToOpenpalmConfig,
8
+ removeHostAkmSource,
9
+ importHostProfiles,
10
+ } from "./akm-sources.js";
11
+ import type { ControlPlaneState } from "./types.js";
12
+
13
+ let root = "";
14
+ let state: ControlPlaneState;
15
+ let opConfigPath = "";
16
+ let hostConfigPath = "";
17
+
18
+ function readJson(p: string): Record<string, unknown> {
19
+ return JSON.parse(readFileSync(p, "utf-8"));
20
+ }
21
+
22
+ beforeEach(() => {
23
+ root = mkdtempSync(join(tmpdir(), "akm-sources-"));
24
+ const configDir = join(root, "config");
25
+ mkdirSync(join(configDir, "akm"), { recursive: true });
26
+ opConfigPath = join(configDir, "akm", "config.json");
27
+ state = { configDir, stashDir: join(root, "knowledge") } as ControlPlaneState;
28
+ hostConfigPath = join(root, "home", ".config", "akm", "config.json");
29
+ });
30
+
31
+ afterEach(() => {
32
+ rmSync(root, { recursive: true, force: true });
33
+ });
34
+
35
+ describe("addHostStashToOpenpalmConfig (assistant side, parse-tolerant)", () => {
36
+ it("adds a writable /host-stash secondary with no primary/defaultWriteTarget", () => {
37
+ addHostStashToOpenpalmConfig(state, true);
38
+ const cfg = readJson(opConfigPath);
39
+ const sources = cfg.sources as Array<Record<string, unknown>>;
40
+ expect(sources).toHaveLength(1);
41
+ expect(sources[0]).toEqual({ type: "filesystem", path: "/host-stash", name: HOST_SOURCE_NAME, writable: true, enabled: true });
42
+ expect(cfg.defaultWriteTarget).toBeUndefined();
43
+ expect(cfg.stashDir).toBeUndefined();
44
+ expect("primary" in sources[0]).toBe(false);
45
+ });
46
+
47
+ it("is idempotent — upserts by name, never duplicates", () => {
48
+ addHostStashToOpenpalmConfig(state, true);
49
+ addHostStashToOpenpalmConfig(state, false);
50
+ const sources = readJson(opConfigPath).sources as Array<Record<string, unknown>>;
51
+ expect(sources).toHaveLength(1);
52
+ expect(sources[0].writable).toBe(false);
53
+ });
54
+
55
+ it("preserves unrelated existing sources and config keys", () => {
56
+ writeFileSync(opConfigPath, JSON.stringify({
57
+ embedding: { model: "nomic-embed-text", dimension: 768 },
58
+ sources: [{ type: "filesystem", path: "/other", name: "other", enabled: true }],
59
+ }));
60
+ addHostStashToOpenpalmConfig(state, true);
61
+ const cfg = readJson(opConfigPath);
62
+ expect((cfg.embedding as Record<string, unknown>).dimension).toBe(768);
63
+ const names = (cfg.sources as Array<Record<string, unknown>>).map((s) => s.name);
64
+ expect(names).toContain("other");
65
+ expect(names).toContain(HOST_SOURCE_NAME);
66
+ });
67
+
68
+ it("recovers from a corrupt OpenPalm config (parse-tolerant → starts from {})", () => {
69
+ writeFileSync(opConfigPath, "{ this is not json");
70
+ addHostStashToOpenpalmConfig(state, true);
71
+ expect((readJson(opConfigPath).sources as unknown[]).length).toBe(1);
72
+ });
73
+
74
+ it("writes mode 0600", () => {
75
+ addHostStashToOpenpalmConfig(state, true);
76
+ expect(statSync(opConfigPath).mode & 0o777).toBe(0o600);
77
+ });
78
+ });
79
+
80
+ describe("removeHostAkmSource (assistant side only — never touches personal config)", () => {
81
+ it("removes the host-akm source, leaving other sources intact", () => {
82
+ writeFileSync(opConfigPath, JSON.stringify({
83
+ sources: [
84
+ { type: "filesystem", path: "/host-stash", name: HOST_SOURCE_NAME },
85
+ { type: "filesystem", path: "/keep", name: "keep" },
86
+ ],
87
+ }));
88
+ removeHostAkmSource(state);
89
+ expect((readJson(opConfigPath).sources as Array<Record<string, unknown>>).map((s) => s.name)).toEqual(["keep"]);
90
+ });
91
+
92
+ it("is idempotent when no host-akm source exists", () => {
93
+ writeFileSync(opConfigPath, JSON.stringify({ sources: [{ name: "keep", type: "filesystem", path: "/k" }] }));
94
+ expect(() => removeHostAkmSource(state)).not.toThrow();
95
+ expect((readJson(opConfigPath).sources as unknown[]).length).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe("importHostProfiles (read-only snapshot of host profiles)", () => {
100
+ function seedHostConfig(obj: Record<string, unknown>): string {
101
+ mkdirSync(join(root, "home", ".config", "akm"), { recursive: true });
102
+ const original = JSON.stringify(obj, null, 2);
103
+ writeFileSync(hostConfigPath, original);
104
+ return original;
105
+ }
106
+
107
+ it("copies llm/agent/improve profiles + defaults + embedding into an empty config", () => {
108
+ seedHostConfig({
109
+ profiles: {
110
+ llm: { default: { endpoint: "http://h/v1/chat/completions", model: "qwen", provider: "ollama" } },
111
+ agent: { default: { platform: "opencode" } },
112
+ improve: { thorough: { limit: 50 } },
113
+ },
114
+ defaults: { llm: "default", agent: "default", improve: "thorough" },
115
+ embedding: { provider: "ollama", model: "nomic-embed-text", dimension: 768 },
116
+ });
117
+ writeFileSync(opConfigPath, "{}");
118
+ const { imported } = importHostProfiles(state, hostConfigPath);
119
+ expect(imported).toContain("profiles.llm");
120
+ expect(imported).toContain("profiles.agent");
121
+ expect(imported).toContain("profiles.improve");
122
+ expect(imported).toContain("defaults.llm");
123
+ expect(imported).toContain("defaults.improve");
124
+ expect(imported).toContain("embedding");
125
+ const cfg = readJson(opConfigPath);
126
+ const profiles = cfg.profiles as Record<string, Record<string, Record<string, unknown>>>;
127
+ expect(profiles.llm.default.model).toBe("qwen");
128
+ expect(profiles.improve.thorough.limit).toBe(50);
129
+ expect((cfg.defaults as Record<string, unknown>).improve).toBe("thorough");
130
+ expect((cfg.embedding as Record<string, unknown>).model).toBe("nomic-embed-text");
131
+ expect((cfg.embedding as Record<string, unknown>).dimension).toBe(768);
132
+ expect(cfg.llm).toBeUndefined();
133
+ });
134
+
135
+ it("is ADDITIVE — never overwrites existing profiles, defaults, or embedding fields", () => {
136
+ seedHostConfig({
137
+ profiles: {
138
+ llm: {
139
+ default: { endpoint: "http://host/v1/chat/completions", model: "host-model" }, // conflicts with existing
140
+ "host-only": { endpoint: "http://host/v1/chat/completions", model: "extra" }, // new → added
141
+ },
142
+ },
143
+ defaults: { llm: "host-only" }, // existing already has defaults.llm → must NOT change
144
+ embedding: { provider: "ollama", model: "host-emb", dimension: 768, batchSize: 32 }, // model conflicts; batchSize new
145
+ });
146
+ writeFileSync(opConfigPath, JSON.stringify({
147
+ profiles: { llm: { default: { endpoint: "http://op/v1/chat/completions", model: "op-model" } } },
148
+ defaults: { llm: "default" },
149
+ embedding: { provider: "openai", model: "op-emb", dimension: 1536 },
150
+ }));
151
+
152
+ const { imported } = importHostProfiles(state, hostConfigPath);
153
+ const cfg = readJson(opConfigPath);
154
+ const profiles = cfg.profiles as Record<string, Record<string, Record<string, unknown>>>;
155
+ // Existing 'default' profile is preserved untouched.
156
+ expect(profiles.llm.default.model).toBe("op-model");
157
+ // Host-only profile is added.
158
+ expect(profiles.llm["host-only"].model).toBe("extra");
159
+ expect(imported).toContain("profiles.llm");
160
+ // Existing default selection is NOT overwritten.
161
+ expect((cfg.defaults as Record<string, unknown>).llm).toBe("default");
162
+ expect(imported).not.toContain("defaults.llm");
163
+ // Embedding: existing fields win; only the new field (batchSize) is added.
164
+ const emb = cfg.embedding as Record<string, unknown>;
165
+ expect(emb.model).toBe("op-emb");
166
+ expect(emb.provider).toBe("openai");
167
+ expect(emb.dimension).toBe(1536);
168
+ expect(emb.batchSize).toBe(32);
169
+ expect(imported).toContain("embedding");
170
+ });
171
+
172
+ it("does not report a namespace as imported when it adds nothing new", () => {
173
+ seedHostConfig({
174
+ profiles: { llm: { default: { endpoint: "x", model: "host" } } },
175
+ defaults: { llm: "default" },
176
+ });
177
+ writeFileSync(opConfigPath, JSON.stringify({
178
+ profiles: { llm: { default: { endpoint: "y", model: "op" } } },
179
+ defaults: { llm: "default" },
180
+ }));
181
+ const { imported } = importHostProfiles(state, hostConfigPath);
182
+ expect(imported).not.toContain("profiles.llm"); // 'default' already present, nothing added
183
+ expect(imported).not.toContain("defaults.llm");
184
+ // existing values untouched
185
+ const cfg = readJson(opConfigPath);
186
+ expect((cfg.profiles as Record<string, Record<string, Record<string, unknown>>>).llm.default.model).toBe("op");
187
+ });
188
+
189
+ it("reads the host config READ-ONLY (host file unchanged byte-for-byte)", () => {
190
+ const original = seedHostConfig({ profiles: { llm: { default: { endpoint: "x", model: "m" } } }, defaults: { llm: "default" } });
191
+ writeFileSync(opConfigPath, "{}");
192
+ importHostProfiles(state, hostConfigPath);
193
+ expect(readFileSync(hostConfigPath, "utf-8")).toBe(original);
194
+ });
195
+
196
+ it("imports nothing (and does not throw) when host has no profiles", () => {
197
+ seedHostConfig({ stashDir: "/home/u/akm" });
198
+ writeFileSync(opConfigPath, "{}");
199
+ expect(importHostProfiles(state, hostConfigPath).imported).toEqual([]);
200
+ });
201
+
202
+ it("throws (fails closed) when the personal config is missing", () => {
203
+ writeFileSync(opConfigPath, "{}");
204
+ expect(() => importHostProfiles(state, hostConfigPath)).toThrow();
205
+ });
206
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Host ↔ Assistant AKM source wiring (control-plane logic — lives in lib).
3
+ *
4
+ * Implements the "symmetric writable secondary" design
5
+ * (docs/technical/akm-host-assistant-integration-proposal.md §8).
6
+ *
7
+ * Each akm instance keeps its OWN primary stash, data dir, and cache. Sharing
8
+ * is done purely by adding the other instance's stash as a *secondary* source
9
+ * in `config.sources[]`:
10
+ * - The OpenPalm/container config gains a `host-akm` source → /host-stash
11
+ * (the user's personal ~/akm, bind-mounted by the host-akm.compose.yml overlay).
12
+ * - The personal config gains an `openpalm` source → OP_HOME/knowledge.
13
+ *
14
+ * VERIFIED against akm 0.8.0-rc.13:
15
+ * - `SourceConfigEntrySchema` (config-schema.ts:259) accepts
16
+ * { type, path?, url?, name?, enabled?, writable?, primary?, options?, wikiName? }.
17
+ * - The indexer's `resolveSourceEntries` (search-source.ts:56) ALWAYS injects the
18
+ * env-resolved primary stash (AKM_STASH_DIR) as sources[0], then appends
19
+ * config.sources[] deduped by path. So a secondary entry can never strand or
20
+ * displace the primary — provided we NEVER set `primary:true` and NEVER set
21
+ * `config.stashDir`.
22
+ * - Writes resolve to the primary unless an explicit `--target` is given
23
+ * (write-source.ts) and `defaultWriteTarget` is left unset, so a writable
24
+ * secondary is safe by construction.
25
+ *
26
+ * Invariants (enforced + unit-tested):
27
+ * - Only ever appends/updates a NAMED source (idempotent upsert by name).
28
+ * - NEVER sets `primary`, NEVER sets `defaultWriteTarget`, NEVER sets `stashDir`.
29
+ * - Atomic 0600 writes.
30
+ * - The OpenPalm config is parse-tolerant (we own it: corrupt → start from {}).
31
+ * - The PERSONAL config FAILS CLOSED (corrupt/unreadable → throw, never overwrite
32
+ * the user's file). This asymmetry is the host-data-loss guard.
33
+ */
34
+ import { readFileSync, existsSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { writeFileAtomic } from "./fs-atomic.js";
37
+ import type { ControlPlaneState } from "./types.js";
38
+
39
+ /** Source entry name added to the OpenPalm/container config (points at /host-stash). */
40
+ export const HOST_SOURCE_NAME = "host-akm";
41
+
42
+ /** A filesystem source entry as akm persists it in config.sources[]. */
43
+ type FilesystemSourceEntry = {
44
+ type: "filesystem";
45
+ path: string;
46
+ name: string;
47
+ writable: boolean;
48
+ enabled: boolean;
49
+ };
50
+
51
+ type AkmConfigObject = Record<string, unknown>;
52
+
53
+ function readConfigTolerant(configPath: string): AkmConfigObject {
54
+ if (!existsSync(configPath)) return {};
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
57
+ return parsed && typeof parsed === "object" ? (parsed as AkmConfigObject) : {};
58
+ } catch {
59
+ // We own the OpenPalm config — a corrupt file is recoverable by rewriting.
60
+ return {};
61
+ }
62
+ }
63
+
64
+ function readConfigFailClosed(configPath: string): AkmConfigObject {
65
+ // The PERSONAL config belongs to the user. If it doesn't exist there is
66
+ // nothing to share into and creating one silently would be surprising;
67
+ // if it's corrupt we must NOT overwrite it. Both cases throw.
68
+ if (!existsSync(configPath)) {
69
+ throw new Error(
70
+ `Personal akm config not found at ${configPath}; refusing to create it. ` +
71
+ `Run \`akm init\`/\`akm setup\` first, then enable host AKM sharing.`,
72
+ );
73
+ }
74
+ let text: string;
75
+ try {
76
+ text = readFileSync(configPath, "utf-8");
77
+ } catch (err) {
78
+ throw new Error(`Unable to read personal akm config at ${configPath}: ${(err as Error).message}`);
79
+ }
80
+ let parsed: unknown;
81
+ try {
82
+ parsed = JSON.parse(text);
83
+ } catch {
84
+ throw new Error(
85
+ `Personal akm config at ${configPath} is not valid JSON; refusing to overwrite it. ` +
86
+ `Fix the file by hand, then retry.`,
87
+ );
88
+ }
89
+ if (!parsed || typeof parsed !== "object") {
90
+ throw new Error(`Personal akm config at ${configPath} is not a JSON object; refusing to overwrite it.`);
91
+ }
92
+ return parsed as AkmConfigObject;
93
+ }
94
+
95
+ /**
96
+ * Upsert a named filesystem source into `config.sources[]` by name. Idempotent.
97
+ * NEVER touches `primary`, `defaultWriteTarget`, or `stashDir`.
98
+ */
99
+ function upsertSource(config: AkmConfigObject, entry: FilesystemSourceEntry): AkmConfigObject {
100
+ const sources = Array.isArray(config.sources) ? [...(config.sources as unknown[])] : [];
101
+ const idx = sources.findIndex(
102
+ (s) => s && typeof s === "object" && (s as Record<string, unknown>).name === entry.name,
103
+ );
104
+ if (idx >= 0) {
105
+ // Preserve any unrelated fields the user set (e.g. options), override ours.
106
+ sources[idx] = { ...(sources[idx] as Record<string, unknown>), ...entry };
107
+ } else {
108
+ sources.push(entry);
109
+ }
110
+ return { ...config, sources };
111
+ }
112
+
113
+ function removeSource(config: AkmConfigObject, name: string): AkmConfigObject {
114
+ if (!Array.isArray(config.sources)) return config;
115
+ const sources = (config.sources as unknown[]).filter(
116
+ (s) => !(s && typeof s === "object" && (s as Record<string, unknown>).name === name),
117
+ );
118
+ return { ...config, sources };
119
+ }
120
+
121
+ function assertNoPrimaryEscalation(entry: FilesystemSourceEntry): void {
122
+ // Defense in depth: the type forbids `primary`, but assert at runtime too —
123
+ // a secondary that became primary would change the write target.
124
+ if ((entry as Record<string, unknown>).primary !== undefined) {
125
+ throw new Error("akm-sources: refusing to write a source entry carrying `primary`.");
126
+ }
127
+ }
128
+
129
+ function openpalmConfigPath(state: ControlPlaneState): string {
130
+ return join(state.configDir, "akm", "config.json");
131
+ }
132
+
133
+ /**
134
+ * Container/OpenPalm side: add the personal stash (mounted at /host-stash) as a
135
+ * secondary source. Parse-tolerant (we own this config). Writable by default so
136
+ * the assistant can contribute back via an explicit `--target host-akm`.
137
+ */
138
+ export function addHostStashToOpenpalmConfig(state: ControlPlaneState, writable = true): void {
139
+ const configPath = openpalmConfigPath(state);
140
+ const entry: FilesystemSourceEntry = {
141
+ type: "filesystem",
142
+ path: "/host-stash",
143
+ name: HOST_SOURCE_NAME,
144
+ writable,
145
+ enabled: true,
146
+ };
147
+ assertNoPrimaryEscalation(entry);
148
+ const config = readConfigTolerant(configPath);
149
+ const updated = upsertSource(config, entry);
150
+ writeFileAtomic(configPath, JSON.stringify(updated, null, 2), 0o600);
151
+ }
152
+
153
+ /**
154
+ * Remove the `host-akm` secondary source from the assistant config (disable
155
+ * sharing). Parse-tolerant; never touches the user's personal config (D1 —
156
+ * host sharing is assistant-reads-host only). Idempotent.
157
+ */
158
+ export function removeHostAkmSource(state: ControlPlaneState): void {
159
+ const opPath = openpalmConfigPath(state);
160
+ const opConfig = readConfigTolerant(opPath);
161
+ writeFileAtomic(opPath, JSON.stringify(removeSource(opConfig, HOST_SOURCE_NAME), null, 2), 0o600);
162
+ }
163
+
164
+ /**
165
+ * Read-only snapshot import of the host's reusable akm config into the OpenPalm
166
+ * config. Reads the personal config READ-ONLY; never writes back to the host.
167
+ *
168
+ * ADDITIVE MERGE: existing OpenPalm values ALWAYS win — the host only fills gaps.
169
+ * A profile name, default selection, or embedding field that OpenPalm already has
170
+ * is never overwritten; host-only profiles/fields are added. Covers the
171
+ * LLM/agent/improve PROFILES (+ their `defaults.*`) and the top-level `embedding`
172
+ * connection. Returns which sections actually gained values.
173
+ *
174
+ * Writes the canonical akm 0.8.0 shape (profiles.* + defaults.* + embedding) —
175
+ * never the legacy top-level `llm` (see I-3). NEVER touches `sources`, `stashDir`,
176
+ * `registries`, or `installed`.
177
+ */
178
+ export function importHostProfiles(
179
+ state: ControlPlaneState,
180
+ hostConfigPath: string,
181
+ ): { imported: string[] } {
182
+ const host = readConfigFailClosed(hostConfigPath);
183
+ const hostProfiles = (host.profiles as Record<string, unknown> | undefined) ?? {};
184
+ const hostDefaults = (host.defaults as Record<string, unknown> | undefined) ?? {};
185
+
186
+ const opPath = openpalmConfigPath(state);
187
+ const op = readConfigTolerant(opPath);
188
+ const opProfiles = (op.profiles as Record<string, unknown> | undefined) ?? {};
189
+ const opDefaults = (op.defaults as Record<string, unknown> | undefined) ?? {};
190
+
191
+ const imported: string[] = [];
192
+
193
+ // ADDITIVE MERGE — existing OpenPalm values always win; the host fills only
194
+ // gaps. Never overwrite a profile, default selection, or embedding field the
195
+ // operator/wizard already set. `imported` lists what was actually added.
196
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
197
+
198
+ // All three profile namespaces akm supports (config-schema.ts ProfilesSchema).
199
+ for (const ns of ["llm", "agent", "improve"] as const) {
200
+ if (isObj(hostProfiles[ns])) {
201
+ const existing = isObj(opProfiles[ns]) ? (opProfiles[ns] as Record<string, unknown>) : {};
202
+ // host first, existing last → existing wins; only host-only profile names are added.
203
+ const merged: Record<string, unknown> = { ...(hostProfiles[ns] as Record<string, unknown>), ...existing };
204
+ const added = Object.keys(merged).length - Object.keys(existing).length;
205
+ opProfiles[ns] = merged;
206
+ if (added > 0) imported.push(`profiles.${ns}`);
207
+ }
208
+ // Only adopt a host default selection when OpenPalm has none.
209
+ if (typeof hostDefaults[ns] === "string" && typeof opDefaults[ns] !== "string") {
210
+ opDefaults[ns] = hostDefaults[ns];
211
+ imported.push(`defaults.${ns}`);
212
+ }
213
+ }
214
+
215
+ // Top-level embedding connection (EmbeddingConnectionConfigSchema). Per-field
216
+ // additive: existing OpenPalm fields win; host fills only missing fields.
217
+ let embedding: Record<string, unknown> | undefined;
218
+ if (isObj(host.embedding)) {
219
+ const existing = isObj(op.embedding) ? (op.embedding as Record<string, unknown>) : {};
220
+ const merged: Record<string, unknown> = { ...(host.embedding as Record<string, unknown>), ...existing };
221
+ if (Object.keys(merged).length > Object.keys(existing).length) {
222
+ embedding = merged;
223
+ imported.push("embedding");
224
+ }
225
+ }
226
+
227
+ if (imported.length === 0) return { imported };
228
+
229
+ const updated: AkmConfigObject = { ...op, profiles: opProfiles, defaults: opDefaults };
230
+ if (embedding !== undefined) updated.embedding = embedding;
231
+ delete (updated as Record<string, unknown>).llm; // never persist the legacy key
232
+ writeFileAtomic(opPath, JSON.stringify(updated, null, 2), 0o600);
233
+ return { imported };
234
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Tests for the akm user-env helpers (`env:user`).
3
+ *
4
+ * akm (>= 0.8.0) no longer manages individual env entries, so OpenPalm owns the
5
+ * `knowledge/env/user.env` file directly. Writes/deletes are plain atomic .env
6
+ * edits — no akm subprocess — so these tests run everywhere (no akm-on-PATH gate).
7
+ */
8
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, statSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ ensureAkmUserEnv,
14
+ readUserEnvSync,
15
+ writeUserEnvKey,
16
+ deleteUserEnvKey,
17
+ userEnvPathSync,
18
+ AKM_USER_ENV_REF,
19
+ buildAkmEnv,
20
+ assertAkmEnvComplete,
21
+ AKM_ENV_KEYS,
22
+ } from "./akm-user-env.js";
23
+ import type { ControlPlaneState } from "./types.js";
24
+
25
+ function makeState(homeDir: string): ControlPlaneState {
26
+ return {
27
+ homeDir,
28
+ configDir: join(homeDir, "config"),
29
+ stashDir: join(homeDir, "knowledge"),
30
+ workspaceDir: join(homeDir, "workspace"),
31
+ dataDir: join(homeDir, "data"),
32
+ stackDir: join(homeDir, "config", "stack"),
33
+ services: {},
34
+ artifacts: { compose: "" },
35
+ artifactMeta: [],
36
+ };
37
+ }
38
+
39
+ describe("akm user-env helpers", () => {
40
+ let homeDir: string;
41
+ let state: ControlPlaneState;
42
+
43
+ beforeEach(() => {
44
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-akm-env-"));
45
+ state = makeState(homeDir);
46
+ mkdirSync(state.stashDir, { recursive: true });
47
+ });
48
+
49
+ afterEach(() => {
50
+ rmSync(homeDir, { recursive: true, force: true });
51
+ });
52
+
53
+ it("ensureAkmUserEnv creates env/user.env (mode 0600) and returns its path", () => {
54
+ const path = ensureAkmUserEnv(state);
55
+ expect(path).toBe(userEnvPathSync(state));
56
+ expect(path).toBe(join(state.stashDir, "env", "user.env"));
57
+ expect(existsSync(path)).toBe(true);
58
+ expect(statSync(path).mode & 0o777).toBe(0o600);
59
+ });
60
+
61
+ it("writeUserEnvKey upserts a key, readUserEnvSync reads it back", () => {
62
+ writeUserEnvKey(state, "TOKEN", "secret-9988");
63
+ expect(readUserEnvSync(state).TOKEN).toBe("secret-9988");
64
+
65
+ // Upsert replaces in place rather than appending a duplicate.
66
+ writeUserEnvKey(state, "TOKEN", "rotated");
67
+ const parsed = readUserEnvSync(state);
68
+ expect(parsed.TOKEN).toBe("rotated");
69
+ const lines = readFileSync(userEnvPathSync(state), "utf-8").split("\n").filter((l) => l.startsWith("TOKEN="));
70
+ expect(lines.length).toBe(1);
71
+ });
72
+
73
+ it("writeUserEnvKey single-quotes values with spaces/special chars (shell-source-safe, dotenv round-trips)", () => {
74
+ writeUserEnvKey(state, "TOKEN", "sk-simple123");
75
+ writeUserEnvKey(state, "OWNER", "Ada Lovelace");
76
+ writeUserEnvKey(state, "URL", "https://x.example/p?a=1&b=2");
77
+ writeUserEnvKey(state, "NOTE", "a#b$c");
78
+
79
+ // dotenv round-trip (what akm env run / the admin endpoint parse).
80
+ const parsed = readUserEnvSync(state);
81
+ expect(parsed.OWNER).toBe("Ada Lovelace");
82
+ expect(parsed.URL).toBe("https://x.example/p?a=1&b=2");
83
+ expect(parsed.NOTE).toBe("a#b$c");
84
+
85
+ // Raw lines: simple tokens stay bare; anything with spaces/shell-meta is
86
+ // POSIX single-quoted so the entrypoint's `set -a; . user.env` is safe
87
+ // (no word-splitting, no `&`/`$` interpretation, no injection).
88
+ const raw = readFileSync(userEnvPathSync(state), "utf-8");
89
+ expect(raw).toContain("TOKEN=sk-simple123\n");
90
+ expect(raw).toContain("OWNER='Ada Lovelace'\n");
91
+ expect(raw).toContain("URL='https://x.example/p?a=1&b=2'\n");
92
+ });
93
+
94
+ it("deleteUserEnvKey removes only the named key", () => {
95
+ writeUserEnvKey(state, "TOKEN_A", "value-a");
96
+ writeUserEnvKey(state, "TOKEN_B", "value-b");
97
+ deleteUserEnvKey(state, "TOKEN_A");
98
+ const parsed = readUserEnvSync(state);
99
+ expect(parsed.TOKEN_A).toBeUndefined();
100
+ expect(parsed.TOKEN_B).toBe("value-b");
101
+ });
102
+
103
+ it("deleteUserEnvKey is idempotent on a missing key", () => {
104
+ expect(() => deleteUserEnvKey(state, "NEVER_SET_KEY")).not.toThrow();
105
+ });
106
+
107
+ it("readUserEnvSync returns {} when no file exists yet", () => {
108
+ expect(readUserEnvSync(state)).toEqual({});
109
+ });
110
+ });
111
+
112
+ describe("AKM_USER_ENV_REF", () => {
113
+ it("exports the canonical akm ref string", () => {
114
+ expect(AKM_USER_ENV_REF).toBe("env:user");
115
+ });
116
+ });
117
+
118
+ describe("assertAkmEnvComplete (I-6 guard)", () => {
119
+ it("passes for the env produced by buildAkmEnv", () => {
120
+ const env = buildAkmEnv({ stashDir: "/k", configDir: "/c", dataDir: "/d" } as ControlPlaneState);
121
+ expect(() => assertAkmEnvComplete(env)).not.toThrow();
122
+ });
123
+
124
+ it("throws when any of the four AKM_* dirs is missing", () => {
125
+ for (const omit of AKM_ENV_KEYS) {
126
+ const env: NodeJS.ProcessEnv = {
127
+ AKM_STASH_DIR: "/s",
128
+ AKM_CONFIG_DIR: "/c",
129
+ AKM_CACHE_DIR: "/ca",
130
+ AKM_DATA_DIR: "/d",
131
+ };
132
+ delete env[omit];
133
+ expect(() => assertAkmEnvComplete(env)).toThrow(omit);
134
+ }
135
+ });
136
+
137
+ it("throws when a key is present but blank", () => {
138
+ expect(() =>
139
+ assertAkmEnvComplete({ AKM_STASH_DIR: "/s", AKM_CONFIG_DIR: " ", AKM_CACHE_DIR: "/ca", AKM_DATA_DIR: "/d" }),
140
+ ).toThrow("AKM_CONFIG_DIR");
141
+ });
142
+ });