@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

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 (66) 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 +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -39,10 +39,12 @@ function run(
39
39
 
40
40
  /**
41
41
  * Resolve the Docker Compose project name.
42
- * Honors COMPOSE_PROJECT_NAME (Docker standard) and OP_PROJECT_NAME (legacy).
42
+ * Honors OP_PROJECT_NAME first for OpenPalm stacks, then COMPOSE_PROJECT_NAME.
43
43
  */
44
- export function resolveComposeProjectName(): string {
44
+ export function resolveComposeProjectName(envOverrides: Record<string, string> = {}): string {
45
45
  return (
46
+ envOverrides.OP_PROJECT_NAME?.trim() ||
47
+ envOverrides.COMPOSE_PROJECT_NAME?.trim() ||
46
48
  process.env.OP_PROJECT_NAME?.trim() ||
47
49
  process.env.COMPOSE_PROJECT_NAME?.trim() ||
48
50
  "openpalm"
@@ -87,12 +89,14 @@ export async function checkDockerCompose(): Promise<DockerResult> {
87
89
  });
88
90
  }
89
91
 
90
- /** Build common prefix: compose -f ... --project-name ... --env-file ... */
91
- function buildComposeArgs(options: { files: string[]; envFiles?: string[] }): string[] {
92
- const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName()];
92
+ /** Build common prefix: compose -f ... --project-name ... --env-file ... --profile ... */
93
+ function buildComposeArgs(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): string[] {
94
+ const envOverrides = collectEnvOverrides(options.envFiles);
95
+ const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName(envOverrides)];
93
96
  for (const ef of options.envFiles ?? []) {
94
97
  if (existsSync(ef)) args.push("--env-file", ef);
95
98
  }
99
+ for (const p of options.profiles ?? []) args.push("--profile", p);
96
100
  return args;
97
101
  }
98
102
 
@@ -108,7 +112,7 @@ function collectEnvOverrides(envFiles?: string[]): Record<string, string> {
108
112
  * Must be called before any lifecycle mutation (install/apply/update).
109
113
  */
110
114
  export async function composePreflight(
111
- options: { files: string[]; envFiles?: string[] }
115
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
112
116
  ): Promise<DockerResult> {
113
117
  const args = buildComposeArgs(options);
114
118
  args.push("config", "--quiet");
@@ -119,22 +123,23 @@ export async function composePreflight(
119
123
  * Run compose config preflight validation before any mutation.
120
124
  * Skipped when OP_SKIP_COMPOSE_PREFLIGHT is set (tests, CI).
121
125
  */
122
- async function runPreflight(options: { files: string[]; envFiles?: string[] }): Promise<void> {
126
+ async function runPreflight(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): Promise<void> {
123
127
  if (options.files.length === 0 || process.env.OP_SKIP_COMPOSE_PREFLIGHT) return;
124
128
  const result = await composePreflight(options);
125
129
  if (!result.ok) {
126
- const project = resolveComposeProjectName();
130
+ const project = resolveComposeProjectName(collectEnvOverrides(options.envFiles));
127
131
  const fileArgs = options.files.map((f) => `-f ${f}`).join(" ");
128
132
  const envArgs = (options.envFiles ?? []).map((f) => `--env-file ${f}`).join(" ");
133
+ const profileArgs = (options.profiles ?? []).map((p) => `--profile ${p}`).join(" ");
129
134
  throw new Error(
130
135
  `Compose preflight failed: ${result.stderr}\n` +
131
- `Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} config --quiet`
136
+ `Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} ${profileArgs} config --quiet`
132
137
  );
133
138
  }
134
139
  }
135
140
 
136
141
  export async function composeConfigServices(
137
- options: { files: string[]; envFiles?: string[] }
142
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
138
143
  ): Promise<{ ok: boolean; services: string[] }> {
139
144
  const args = buildComposeArgs(options);
140
145
  args.push("config", "--services");
@@ -163,7 +168,6 @@ export async function composeUp(
163
168
  return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
164
169
  }
165
170
  const args = buildComposeArgs(options);
166
- for (const p of options.profiles ?? []) args.push("--profile", p);
167
171
  args.push("up", "-d");
168
172
  if (options.forceRecreate) args.push("--force-recreate");
169
173
  if (options.removeOrphans) args.push("--remove-orphans");
@@ -187,7 +191,6 @@ export async function composeDown(
187
191
  return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
188
192
  }
189
193
  const args = buildComposeArgs(options);
190
- for (const p of options.profiles ?? []) args.push("--profile", p);
191
194
  args.push("down");
192
195
  if (options.removeVolumes) args.push("-v");
193
196
  return run(args, undefined);
@@ -313,7 +316,6 @@ export async function composePullService(
313
316
  ): Promise<DockerResult> {
314
317
  await runPreflight(options);
315
318
  const args = buildComposeArgs(options);
316
- for (const p of options.profiles ?? []) args.push("--profile", p);
317
319
  args.push("pull", service);
318
320
  return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
319
321
  }
@@ -323,7 +325,6 @@ export async function composePull(
323
325
  ): Promise<DockerResult> {
324
326
  await runPreflight(options);
325
327
  const args = buildComposeArgs(options);
326
- for (const p of options.profiles ?? []) args.push("--profile", p);
327
328
  args.push("pull");
328
329
  return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
329
330
  }
@@ -86,25 +86,25 @@ describe("quoteEnvValue quoting strategy (via mergeEnvContent)", () => {
86
86
 
87
87
  describe("mergeEnvContent updates existing keys with special char values", () => {
88
88
  it("updates an existing key to a value with =", () => {
89
- const input = "export ADMIN_TOKEN=old_value\n";
90
- const result = mergeEnvContent(input, { ADMIN_TOKEN: "new=value=here" });
89
+ const input = "export TEST_VALUE=old_value\n";
90
+ const result = mergeEnvContent(input, { TEST_VALUE: "new=value=here" });
91
91
  const parsed = parseEnvContent(result);
92
- expect(parsed.ADMIN_TOKEN).toBe("new=value=here");
92
+ expect(parsed.TEST_VALUE).toBe("new=value=here");
93
93
  });
94
94
 
95
95
  it("updates an existing key to a value with $", () => {
96
- const input = "export ADMIN_TOKEN=old_value\n";
97
- const result = mergeEnvContent(input, { ADMIN_TOKEN: "tok$en" });
96
+ const input = "export TEST_VALUE=old_value\n";
97
+ const result = mergeEnvContent(input, { TEST_VALUE: "tok$en" });
98
98
  const parsed = parseEnvContent(result);
99
- expect(parsed.ADMIN_TOKEN).toBe("tok$en");
99
+ expect(parsed.TEST_VALUE).toBe("tok$en");
100
100
  });
101
101
 
102
102
  it("preserves export prefix when updating with special chars", () => {
103
- const input = "export ADMIN_TOKEN=old_value\n";
104
- const result = mergeEnvContent(input, { ADMIN_TOKEN: "new#value" });
105
- expect(result).toMatch(/^export ADMIN_TOKEN=/m);
103
+ const input = "export TEST_VALUE=old_value\n";
104
+ const result = mergeEnvContent(input, { TEST_VALUE: "new#value" });
105
+ expect(result).toMatch(/^export TEST_VALUE=/m);
106
106
  const parsed = parseEnvContent(result);
107
- expect(parsed.ADMIN_TOKEN).toBe("new#value");
107
+ expect(parsed.TEST_VALUE).toBe("new#value");
108
108
  });
109
109
  });
110
110
 
@@ -26,7 +26,7 @@ export function expandEnvVars(input: string, vars: Record<string, string>): stri
26
26
  return input.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
27
27
  }
28
28
 
29
- function quoteEnvValue(value: string): string {
29
+ export function quoteEnvValue(value: string): string {
30
30
  if (value.length === 0) return '';
31
31
  const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
32
32
  if (!needsQuoting) return value;
@@ -82,6 +82,21 @@ export function upsertEnvValue(content: string, key: string, value: string): str
82
82
  return `${content}${suffix}${line}\n`;
83
83
  }
84
84
 
85
+ /** Addon name shape (matches the former stack.yml validation). */
86
+ export const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
87
+
88
+ /**
89
+ * Parse the `OP_ENABLED_ADDONS` stack.env value (comma-separated) into a
90
+ * validated, de-duplicated, sorted list of addon ids. Replaces the former
91
+ * stack.yml `addons[]` array as the authoritative enabled-addon record.
92
+ */
93
+ export function parseEnabledAddons(value: string | undefined): string[] {
94
+ if (!value) return [];
95
+ return [...new Set(
96
+ value.split(',').map((v) => v.trim()).filter((v) => ADDON_NAME_RE.test(v)),
97
+ )].sort();
98
+ }
99
+
85
100
  export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
86
101
 
87
102
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Verify that Compose `extends` is supported as an optional addon pattern.
2
+ * Verify that Compose `extends` is supported in the custom compose file.
3
3
  *
4
4
  * This is a narrow smoke test proving the canonical compose resolution
5
- * works when an addon uses Compose `extends` to inherit from a base service.
5
+ * works when custom.compose.yml uses Compose `extends` to inherit from a base service.
6
6
  */
7
7
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
8
8
  import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
@@ -15,7 +15,7 @@ describe("compose extends support", () => {
15
15
 
16
16
  beforeAll(() => {
17
17
  fixtureDir = join(tmpdir(), `openpalm-extends-test-${Date.now()}`);
18
- mkdirSync(join(fixtureDir, "stack/addons/extended-addon"), { recursive: true });
18
+ mkdirSync(join(fixtureDir, "stack"), { recursive: true });
19
19
 
20
20
  // Write a minimal core compose
21
21
  writeFileSync(
@@ -30,9 +30,9 @@ describe("compose extends support", () => {
30
30
  ].join("\n")
31
31
  );
32
32
 
33
- // Write an addon that uses `extends`
33
+ // Write custom compose content that uses `extends`
34
34
  writeFileSync(
35
- join(fixtureDir, "stack/addons/extended-addon/compose.yml"),
35
+ join(fixtureDir, "stack/custom.compose.yml"),
36
36
  [
37
37
  "services:",
38
38
  " extended-service:",
@@ -54,16 +54,16 @@ describe("compose extends support", () => {
54
54
 
55
55
  test("fixture files exist", () => {
56
56
  expect(existsSync(join(fixtureDir, "stack/core.compose.yml"))).toBe(true);
57
- expect(existsSync(join(fixtureDir, "stack/addons/extended-addon/compose.yml"))).toBe(true);
57
+ expect(existsSync(join(fixtureDir, "stack/custom.compose.yml"))).toBe(true);
58
58
  });
59
59
 
60
- test("extends addon composes correctly with discoverStackOverlays", async () => {
60
+ test("extends custom compose works with discoverStackOverlays", async () => {
61
61
  const { discoverStackOverlays } = await import("./config-persistence.js");
62
62
  const overlays = discoverStackOverlays(join(fixtureDir, "stack"));
63
63
 
64
64
  expect(overlays.length).toBe(2);
65
65
  expect(overlays[0]).toContain("core.compose.yml");
66
- expect(overlays[1]).toContain("extended-addon/compose.yml");
66
+ expect(overlays[1]).toContain("custom.compose.yml");
67
67
  });
68
68
 
69
69
  test.skipIf(skipDockerAssertions)("extends addon passes docker compose config preflight (requires Docker)", async () => {
@@ -0,0 +1,15 @@
1
+ import { writeFileSync, renameSync } from "node:fs";
2
+
3
+ /**
4
+ * Write a file atomically: write to `${path}.tmp` then rename over the target.
5
+ * The rename is atomic on the same filesystem, so readers never observe a
6
+ * partially written file. `mode` (e.g. 0o600) is applied on creation.
7
+ *
8
+ * Shared by all control-plane writers (setup, akm-sources, …) so config and
9
+ * secret files are written through one audited path — never hand-rolled.
10
+ */
11
+ export function writeFileAtomic(path: string, content: string | Uint8Array, mode?: number): void {
12
+ const tmp = `${path}.tmp`;
13
+ writeFileSync(tmp, content, mode !== undefined ? { mode } : {});
14
+ renameSync(tmp, path);
15
+ }
@@ -2,13 +2,12 @@
2
2
  * Home directory layout for the OpenPalm control plane (v0.11.0+).
3
3
  *
4
4
  * Single ~/.openpalm/ root:
5
- * config/ — user-editable config + system config files (auth.json, akm/)
6
- * config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
7
- * cache/ regenerable/semi-persistent data (akm cache, guardian cache, rollback)
8
- * state/ persistent service data (assistant, admin, guardian, logs, backups, registry)
9
- * stash/ — akm knowledge (skills, vaults, agents)
5
+ * config/ — user-editable config + system config files (akm/)
6
+ * config/stack/ — fixed compose files (no stack.env/secrets/stack.yml)
7
+ * data/ — persistent service data, logs, backups, rollback
8
+ * knowledge/ akm knowledge (env, secrets, tasks); env/stack.env is the
9
+ * authoritative stack composition + versions record
10
10
  * workspace/ — shared assistant work area
11
- * config/stack/ — compose runtime assets + stack config (stack.env, guardian.env, stack.yml)
12
11
  */
13
12
  import { mkdirSync } from "node:fs";
14
13
  import { homedir, tmpdir } from "node:os";
@@ -34,36 +33,31 @@ export function resolveConfigDir(): string {
34
33
  }
35
34
 
36
35
  export function resolveStashDir(): string {
37
- return `${resolveOpenPalmHome()}/stash`;
36
+ return `${resolveOpenPalmHome()}/knowledge`;
38
37
  }
39
38
 
40
39
  export function resolveWorkspaceDir(): string {
41
40
  return `${resolveOpenPalmHome()}/workspace`;
42
41
  }
43
42
 
44
- export function resolveCacheDir(): string {
45
- return `${resolveOpenPalmHome()}/cache`;
46
- }
47
-
48
- export function resolveStateDir(): string {
49
- return `${resolveOpenPalmHome()}/state`;
43
+ export function resolveDataDir(): string {
44
+ return `${resolveOpenPalmHome()}/data`;
50
45
  }
51
46
 
52
47
  export function resolveStackDir(): string {
53
48
  return `${resolveConfigDir()}/stack`;
54
49
  }
55
50
 
56
- // Derived from stateDir — used by registry.ts, rollback.ts, backup.ts, core-assets.ts
57
51
  export function resolveLogsDir(): string {
58
- return `${resolveStateDir()}/logs`;
52
+ return `${resolveDataDir()}/logs`;
59
53
  }
60
54
 
61
55
  export function resolveBackupsDir(): string {
62
- return `${resolveStateDir()}/backups`;
56
+ return `${resolveDataDir()}/backups`;
63
57
  }
64
58
 
65
59
  export function resolveRegistryDir(): string {
66
- return `${resolveStateDir()}/registry`;
60
+ return `${resolveDataDir()}/registry`;
67
61
  }
68
62
 
69
63
  export function resolveRegistryAddonsDir(): string {
@@ -75,7 +69,7 @@ export function resolveRegistryAutomationsDir(): string {
75
69
  }
76
70
 
77
71
  export function resolveRollbackDir(): string {
78
- return `${resolveCacheDir()}/rollback`;
72
+ return `${resolveDataDir()}/rollback`;
79
73
  }
80
74
 
81
75
  // ── Directory Setup ──────────────────────────────────────────────────
@@ -91,39 +85,33 @@ export function ensureHomeDirs(): void {
91
85
  `${home}/config`,
92
86
  `${home}/config/assistant`,
93
87
  `${home}/config/guardian`,
94
- `${home}/config/akm`, // AKM_CONFIG_DIR — akm setup config.json lives here
95
-
96
- // cache/ — regenerable/semi-persistent data
97
- `${home}/cache`,
98
- `${home}/cache/akm`, // akm registry index, downloaded artifacts
99
- `${home}/cache/rollback`, // rollback snapshots
100
-
101
- // state/ — persistent service data
102
- `${home}/state`,
103
- `${home}/state/assistant`, // assistant HOME bind mount
104
- `${home}/state/admin`, // admin home bind mount
105
- `${home}/state/guardian`, // guardian runtime data
106
- `${home}/state/akm`, // shared akm operational data (NOT config)
107
- `${home}/state/akm/data`,
108
- `${home}/state/akm/state`,
109
- `${home}/state/logs`,
110
- `${home}/state/logs/opencode`,
111
- `${home}/state/backups`,
112
- `${home}/state/registry`,
113
- `${home}/state/registry/addons`,
114
- `${home}/state/registry/automations`,
115
-
116
- // stash/ — akm knowledge (skills, vaults, agents); stash/tasks/ for scheduled automations
117
- `${home}/stash`,
118
- `${home}/stash/vaults`,
119
- `${home}/stash/tasks`,
88
+ `${home}/config/akm`, // akm XDG config directory
89
+
90
+ // data/ — persistent service data
91
+ `${home}/data`,
92
+ `${home}/data/assistant`, // assistant HOME bind mount
93
+ `${home}/data/assistant/.cache`,
94
+ `${home}/data/assistant/.local/bin`,
95
+ `${home}/data/assistant/.local/share/opencode`,
96
+ `${home}/data/assistant/.local/state/opencode`,
97
+ `${home}/data/guardian`, // guardian runtime data
98
+ `${home}/data/akm/cache`, // akm cache
99
+ `${home}/data/akm/data`, // akm durable data
100
+ `${home}/data/akm/empty-host-stash`, // always-present /host-stash fallback when host AKM is absent
101
+ `${home}/data/logs`, // service logs and audit files
102
+ `${home}/data/backups`, // lifecycle backup snapshots
103
+ `${home}/data/rollback`, // deploy rollback snapshots
104
+ // knowledge/ — akm knowledge (skills, env, secrets, agents); knowledge/tasks/ for scheduled automations
105
+ `${home}/knowledge`,
106
+ `${home}/knowledge/env`,
107
+ `${home}/knowledge/secrets`,
108
+ `${home}/knowledge/tasks`,
120
109
 
121
110
  // workspace/ — shared assistant work area
122
111
  `${home}/workspace`,
123
112
 
124
- // config/stack/ — compose runtime (addon overlays + stack config files)
113
+ // config/stack/ — compose runtime + stack config files
125
114
  `${home}/config/stack`,
126
- `${home}/config/stack/addons`,
127
115
  ]) {
128
116
  mkdirSync(dir, { recursive: true });
129
117
  }
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ enableHostAkmSharing,
7
+ disableHostAkmSharing,
8
+ getHostAkmSharingStatus,
9
+ ensureHostStashEnv,
10
+ isHostAkmAvailable,
11
+ } from "./host-akm-sharing.js";
12
+ import { HOST_SOURCE_NAME } from "./akm-sources.js";
13
+ import type { ControlPlaneState } from "./types.js";
14
+
15
+ let root = "";
16
+ let fakeHome = "";
17
+ let state: ControlPlaneState;
18
+ let stackEnv = "";
19
+ let opConfig = "";
20
+ const savedHome = process.env.HOME;
21
+
22
+ function readJson(p: string): Record<string, unknown> {
23
+ return JSON.parse(readFileSync(p, "utf-8"));
24
+ }
25
+ /** Make the host look like it has (or hasn't) an initialized AKM. */
26
+ function setHostAkm(available: boolean): void {
27
+ if (available) {
28
+ mkdirSync(join(fakeHome, "akm"), { recursive: true });
29
+ mkdirSync(join(fakeHome, ".config", "akm"), { recursive: true });
30
+ writeFileSync(join(fakeHome, ".config", "akm", "config.json"), JSON.stringify({ stashDir: join(fakeHome, "akm") }));
31
+ }
32
+ }
33
+ function opSources(): Array<Record<string, unknown>> {
34
+ if (!existsSync(opConfig)) return [];
35
+ return (readJson(opConfig).sources as Array<Record<string, unknown>>) ?? [];
36
+ }
37
+
38
+ beforeEach(() => {
39
+ root = mkdtempSync(join(tmpdir(), "host-akm-"));
40
+ fakeHome = join(root, "home");
41
+ mkdirSync(fakeHome, { recursive: true });
42
+ process.env.HOME = fakeHome;
43
+ const configDir = join(root, "config");
44
+ const stashDir = join(root, "knowledge");
45
+ mkdirSync(join(configDir, "akm"), { recursive: true });
46
+ mkdirSync(join(stashDir, "env"), { recursive: true });
47
+ state = { configDir, stashDir, dataDir: join(root, "data"), homeDir: root } as ControlPlaneState;
48
+ stackEnv = join(stashDir, "env", "stack.env");
49
+ opConfig = join(configDir, "akm", "config.json");
50
+ });
51
+
52
+ afterEach(() => {
53
+ rmSync(root, { recursive: true, force: true });
54
+ if (savedHome === undefined) delete process.env.HOME;
55
+ else process.env.HOME = savedHome;
56
+ delete process.env.OP_HOST_AKM_STASH;
57
+ });
58
+
59
+ describe("isHostAkmAvailable", () => {
60
+ it("is false without a personal akm config, true with one", () => {
61
+ expect(isHostAkmAvailable()).toBe(false);
62
+ setHostAkm(true);
63
+ expect(isHostAkmAvailable()).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe("ensureHostStashEnv", () => {
68
+ it("sets OP_HOST_AKM_STASH to ~/akm when available", () => {
69
+ setHostAkm(true);
70
+ ensureHostStashEnv(state);
71
+ expect(readFileSync(stackEnv, "utf-8")).toContain(`OP_HOST_AKM_STASH=${join(fakeHome, "akm")}`);
72
+ });
73
+
74
+ it("removes OP_HOST_AKM_STASH when not available (→ compose empty-dir fallback)", () => {
75
+ writeFileSync(stackEnv, "OP_HOST_AKM_STASH=/stale/path\nOP_IMAGE_TAG=x\n");
76
+ ensureHostStashEnv(state);
77
+ const env = readFileSync(stackEnv, "utf-8");
78
+ expect(env).not.toContain("OP_HOST_AKM_STASH");
79
+ expect(env).toContain("OP_IMAGE_TAG=x");
80
+ });
81
+ });
82
+
83
+ describe("enableHostAkmSharing", () => {
84
+ it("sets env + adds the writable host-akm source when available", () => {
85
+ setHostAkm(true);
86
+ enableHostAkmSharing(state);
87
+ expect(readFileSync(stackEnv, "utf-8")).toContain(`OP_HOST_AKM_STASH=${join(fakeHome, "akm")}`);
88
+ const src = opSources().find((s) => s.name === HOST_SOURCE_NAME);
89
+ expect(src).toBeDefined();
90
+ expect(src!.writable).toBe(true);
91
+ expect(src!.path).toBe("/host-stash");
92
+ });
93
+
94
+ it("throws when host AKM is not available (never writes a source)", () => {
95
+ expect(() => enableHostAkmSharing(state)).toThrow();
96
+ expect(opSources()).toHaveLength(0);
97
+ });
98
+
99
+ it("imports host profiles when importProfiles is set", () => {
100
+ setHostAkm(true);
101
+ writeFileSync(join(fakeHome, ".config", "akm", "config.json"), JSON.stringify({
102
+ stashDir: join(fakeHome, "akm"),
103
+ profiles: { llm: { default: { endpoint: "http://h/v1/chat/completions", model: "qwen" } } },
104
+ defaults: { llm: "default" },
105
+ }));
106
+ const { profilesImported } = enableHostAkmSharing(state, { importProfiles: true });
107
+ expect(profilesImported).toContain("profiles.llm");
108
+ expect(((readJson(opConfig).profiles as Record<string, Record<string, Record<string, unknown>>>).llm.default).model).toBe("qwen");
109
+ });
110
+
111
+ it("is idempotent", () => {
112
+ setHostAkm(true);
113
+ enableHostAkmSharing(state);
114
+ enableHostAkmSharing(state);
115
+ expect(opSources().filter((s) => s.name === HOST_SOURCE_NAME)).toHaveLength(1);
116
+ });
117
+ });
118
+
119
+ describe("disableHostAkmSharing", () => {
120
+ it("removes the host-akm source; never deletes stash content or the personal config", () => {
121
+ setHostAkm(true);
122
+ enableHostAkmSharing(state);
123
+ disableHostAkmSharing(state);
124
+ expect(opSources().find((s) => s.name === HOST_SOURCE_NAME)).toBeUndefined();
125
+ // Personal config untouched (D1 — assistant-only).
126
+ expect(existsSync(join(fakeHome, ".config", "akm", "config.json"))).toBe(true);
127
+ });
128
+
129
+ it("is safe when nothing is enabled", () => {
130
+ writeFileSync(opConfig, "{}");
131
+ expect(() => disableHostAkmSharing(state)).not.toThrow();
132
+ });
133
+ });
134
+
135
+ describe("getHostAkmSharingStatus", () => {
136
+ it("reports available+enabled transitions", () => {
137
+ expect(getHostAkmSharingStatus(state)).toEqual({ available: false, enabled: false, hostStashPath: null });
138
+ setHostAkm(true);
139
+ expect(getHostAkmSharingStatus(state)).toEqual({ available: true, enabled: false, hostStashPath: join(fakeHome, "akm") });
140
+ enableHostAkmSharing(state);
141
+ expect(getHostAkmSharingStatus(state).enabled).toBe(true);
142
+ disableHostAkmSharing(state);
143
+ expect(getHostAkmSharingStatus(state).enabled).toBe(false);
144
+ });
145
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Host AKM sharing (control-plane logic — lives in lib).
3
+ *
4
+ * Simplified model (no compose overlay, no file-presence gating):
5
+ *
6
+ * - The assistant ALWAYS mounts `/host-stash` (core.compose.yml). When the host
7
+ * has AKM, OP_HOST_AKM_STASH points at the user's personal stash (~/akm);
8
+ * otherwise it is unset and compose falls back to an always-present empty dir.
9
+ * - "Sharing" is purely a writable SECONDARY source entry named `host-akm` →
10
+ * /host-stash in the assistant's config/akm/config.json. Adding it = enabled;
11
+ * removing it = disabled. akm resolves writes to the primary unless an explicit
12
+ * --target is given, and silently skips a source whose dir is empty/missing —
13
+ * so a mounted-but-unconfigured /host-stash is harmless.
14
+ * - Host availability is detected from the presence of the user's personal akm
15
+ * CONFIG (~/.config/akm/config.json) — the real signal that akm is initialized.
16
+ *
17
+ * Decision D1 (2026-06-03): host sharing is assistant-reads-host ONLY by default.
18
+ * We never write into the user's personal ~/.config/akm here. (Letting the host
19
+ * akm see OpenPalm's knowledge is a future, explicit opt-in.)
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { writeFileAtomic } from "./fs-atomic.js";
24
+ import { mergeEnvContent, removeEnvKey } from "./env.js";
25
+ import { addHostStashToOpenpalmConfig, removeHostAkmSource, importHostProfiles } from "./akm-sources.js";
26
+ import type { ControlPlaneState } from "./types.js";
27
+ import { createLogger } from "../logger.js";
28
+
29
+ const logger = createLogger("host-akm-sharing");
30
+
31
+ const ENV_KEY = "OP_HOST_AKM_STASH";
32
+
33
+ function userHome(): string {
34
+ return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
35
+ }
36
+ /** The user's personal akm stash dir (mounted into the assistant at /host-stash). */
37
+ export function hostAkmStashPath(): string {
38
+ return `${userHome()}/akm`;
39
+ }
40
+ /** The user's personal akm config file — its existence is our availability signal. */
41
+ export function hostAkmConfigPath(): string {
42
+ return `${userHome()}/.config/akm/config.json`;
43
+ }
44
+ /** True when AKM is initialized on the host (personal config exists). */
45
+ export function isHostAkmAvailable(): boolean {
46
+ return existsSync(hostAkmConfigPath());
47
+ }
48
+
49
+ function stackEnvPath(state: ControlPlaneState): string {
50
+ return `${state.stashDir}/env/stack.env`;
51
+ }
52
+
53
+ /**
54
+ * Point OP_HOST_AKM_STASH at the host stash when AKM is available, else unset it
55
+ * (compose then uses the empty-dir fallback). Pure infrastructure — does NOT
56
+ * change the source list. Idempotent; safe to call on setup and on deploy.
57
+ */
58
+ export function ensureHostStashEnv(state: ControlPlaneState): void {
59
+ const path = stackEnvPath(state);
60
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
61
+ const updated = isHostAkmAvailable()
62
+ ? mergeEnvContent(existing, { [ENV_KEY]: hostAkmStashPath() })
63
+ : removeEnvKey(existing, ENV_KEY);
64
+ if (updated !== existing) writeFileAtomic(path, updated, 0o600);
65
+ }
66
+
67
+ export type HostAkmSharingStatus = {
68
+ /** AKM is initialized on the host (personal config present). */
69
+ available: boolean;
70
+ /** The host-akm secondary source is present in the assistant config. */
71
+ enabled: boolean;
72
+ /** Resolved host stash path when available, else null. */
73
+ hostStashPath: string | null;
74
+ };
75
+
76
+ /**
77
+ * Enable host AKM sharing: ensure OP_HOST_AKM_STASH points at ~/akm and add the
78
+ * writable `host-akm` secondary source to the assistant config. Optionally import
79
+ * host LLM/agent profiles (read-only). Throws if host AKM is not available.
80
+ */
81
+ export function enableHostAkmSharing(
82
+ state: ControlPlaneState,
83
+ opts: { writable?: boolean; importProfiles?: boolean } = {},
84
+ ): { profilesImported: string[] } {
85
+ if (!isHostAkmAvailable()) {
86
+ throw new Error(
87
+ `Host AKM is not available (no ${hostAkmConfigPath()}). Run \`akm init\` on the host first.`,
88
+ );
89
+ }
90
+ ensureHostStashEnv(state);
91
+ addHostStashToOpenpalmConfig(state, opts.writable ?? true);
92
+ let profilesImported: string[] = [];
93
+ if (opts.importProfiles) {
94
+ profilesImported = importHostProfiles(state, hostAkmConfigPath()).imported;
95
+ }
96
+ logger.info("host akm sharing enabled", { hostStashPath: hostAkmStashPath(), profilesImported });
97
+ return { profilesImported };
98
+ }
99
+
100
+ /**
101
+ * Disable host AKM sharing: remove the `host-akm` secondary source from the
102
+ * assistant config. Leaves the (harmless) mount and env in place; never deletes
103
+ * any stash content.
104
+ */
105
+ export function disableHostAkmSharing(state: ControlPlaneState): void {
106
+ removeHostAkmSource(state);
107
+ logger.info("host akm sharing disabled");
108
+ }
109
+
110
+ /** Report availability + whether the host-akm source is currently configured. */
111
+ export function getHostAkmSharingStatus(state: ControlPlaneState): HostAkmSharingStatus {
112
+ const available = isHostAkmAvailable();
113
+ return {
114
+ available,
115
+ enabled: openpalmHasHostSource(state),
116
+ hostStashPath: available ? hostAkmStashPath() : null,
117
+ };
118
+ }
119
+
120
+ function openpalmHasHostSource(state: ControlPlaneState): boolean {
121
+ const path = `${state.configDir}/akm/config.json`;
122
+ if (!existsSync(path)) return false;
123
+ try {
124
+ const cfg = JSON.parse(readFileSync(path, "utf-8")) as { sources?: Array<{ name?: string }> };
125
+ return Array.isArray(cfg.sources) && cfg.sources.some((s) => s?.name === "host-akm");
126
+ } catch {
127
+ return false;
128
+ }
129
+ }