@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
@@ -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/ — compose runtime + stack config (stack.env, stack.yml, auth.json, fixed compose files)
7
+ * data/ — persistent service data, logs, backups, rollback
8
+ * knowledge/ akm knowledge (env, secrets, tasks)
10
9
  * workspace/ — shared assistant work area
11
- * config/stack/ — compose runtime assets + stack config (stack.env, guardian.env, stack.yml)
10
+ * config/stack/ — compose runtime assets + stack config (stack.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
+ }
@@ -16,10 +16,9 @@ function makeState(homeDir: string): ControlPlaneState {
16
16
  return {
17
17
  homeDir,
18
18
  configDir: join(homeDir, "config"),
19
- stashDir: join(homeDir, "stash"),
19
+ stashDir: join(homeDir, "knowledge"),
20
20
  workspaceDir: join(homeDir, "workspace"),
21
- cacheDir: join(homeDir, "cache"),
22
- stateDir: join(homeDir, "state"),
21
+ dataDir: join(homeDir, "data"),
23
22
  stackDir: join(homeDir, "config/stack"),
24
23
  services: {},
25
24
  artifacts: { compose: "" },
@@ -104,6 +103,48 @@ describe("detectHostOpenCode", () => {
104
103
  expect(status.providerCount).toBe(0);
105
104
  });
106
105
  });
106
+
107
+ it("returns modelPreferences when model and small_model are set", () => {
108
+ const configDir = join(xdgRoot, "config", "opencode");
109
+ mkdirSync(configDir, { recursive: true });
110
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
111
+ provider: { groq: {} },
112
+ model: "groq/llama-3.3-70b-versatile",
113
+ small_model: "groq/llama-3.1-8b-instant",
114
+ }));
115
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
116
+ const status = detectHostOpenCode();
117
+ expect(status.modelPreferences).toBeDefined();
118
+ expect(status.modelPreferences?.model).toBe("groq/llama-3.3-70b-versatile");
119
+ expect(status.modelPreferences?.small_model).toBe("groq/llama-3.1-8b-instant");
120
+ });
121
+ });
122
+
123
+ it("omits modelPreferences when no model fields are set", () => {
124
+ const configDir = join(xdgRoot, "config", "opencode");
125
+ mkdirSync(configDir, { recursive: true });
126
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
127
+ provider: { groq: {} },
128
+ }));
129
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
130
+ const status = detectHostOpenCode();
131
+ expect(status.modelPreferences).toBeUndefined();
132
+ });
133
+ });
134
+
135
+ it("returns partial modelPreferences when only model is set", () => {
136
+ const configDir = join(xdgRoot, "config", "opencode");
137
+ mkdirSync(configDir, { recursive: true });
138
+ writeFileSync(join(configDir, "opencode.json"), JSON.stringify({
139
+ provider: { anthropic: {} },
140
+ model: "anthropic/claude-sonnet-4-5",
141
+ }));
142
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
143
+ const status = detectHostOpenCode();
144
+ expect(status.modelPreferences?.model).toBe("anthropic/claude-sonnet-4-5");
145
+ expect(status.modelPreferences?.small_model).toBeUndefined();
146
+ });
147
+ });
107
148
  });
108
149
 
109
150
  // ── importHostOpenCode ────────────────────────────────────────────────────────
@@ -132,6 +173,8 @@ describe("importHostOpenCode", () => {
132
173
  writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
133
174
  provider: { anthropic: { name: "Anthropic" }, groq: {} },
134
175
  model: "anthropic/claude-3-5-sonnet",
176
+ small_model: "openai/gpt-4o-mini",
177
+ disabled_providers: ["groq"],
135
178
  // These should be stripped:
136
179
  plugin: [{ module: "some-plugin" }],
137
180
  mcp: { server: {} },
@@ -153,14 +196,16 @@ describe("importHostOpenCode", () => {
153
196
  const destConfig = JSON.parse(readFileSync(join(opHome, "config", "assistant", "opencode.json"), "utf-8"));
154
197
  expect(destConfig.provider).toEqual({ anthropic: { name: "Anthropic" }, groq: {} });
155
198
  expect(destConfig.model).toBe("anthropic/claude-3-5-sonnet");
199
+ expect(destConfig.small_model).toBe("openai/gpt-4o-mini");
200
+ expect(destConfig.disabled_providers).toEqual(["groq"]);
156
201
  expect(destConfig.plugin).toBeUndefined();
157
202
  expect(destConfig.mcp).toBeUndefined();
158
203
 
159
204
  // Verify auth.json was written
160
- expect(existsSync(join(opHome, "config", "auth.json"))).toBe(true);
205
+ expect(existsSync(join(opHome, "knowledge", "secrets", "auth.json"))).toBe(true);
161
206
 
162
207
  // Verify auth.json permissions are 0o600
163
- const authStat = statSync(join(opHome, "config", "auth.json"));
208
+ const authStat = statSync(join(opHome, "knowledge", "secrets", "auth.json"));
164
209
  // On Linux, mode & 0o777 extracts permission bits
165
210
  expect(authStat.mode & 0o777).toBe(0o600);
166
211
  });
@@ -217,6 +262,34 @@ describe("importHostOpenCode", () => {
217
262
  expect(written.provider.anthropic.name).toBe("Host Anthropic");
218
263
  });
219
264
 
265
+ it("keeps existing model defaults and fills only missing host fields", () => {
266
+ const hostConfigDir = join(xdgRoot, "config", "opencode");
267
+ mkdirSync(hostConfigDir, { recursive: true });
268
+ writeFileSync(join(hostConfigDir, "opencode.json"), JSON.stringify({
269
+ provider: { openai: { name: "Host OpenAI" } },
270
+ model: "openai/gpt-4.1",
271
+ small_model: "openai/gpt-4.1-mini",
272
+ disabled_providers: ["groq"],
273
+ }));
274
+
275
+ const state = makeState(opHome);
276
+ const destDir = join(opHome, "config", "assistant");
277
+ mkdirSync(destDir, { recursive: true });
278
+ writeFileSync(join(destDir, "opencode.json"), JSON.stringify({
279
+ provider: { anthropic: { name: "Existing Anthropic" } },
280
+ model: "anthropic/claude-sonnet-4",
281
+ }));
282
+
283
+ withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
284
+ importHostOpenCode(state);
285
+ });
286
+
287
+ const written = JSON.parse(readFileSync(join(destDir, "opencode.json"), "utf-8"));
288
+ expect(written.model).toBe("anthropic/claude-sonnet-4");
289
+ expect(written.small_model).toBe("openai/gpt-4.1-mini");
290
+ expect(written.disabled_providers).toEqual(["groq"]);
291
+ });
292
+
220
293
  it("returns zero counts when no host config is present", () => {
221
294
  const state = makeState(opHome);
222
295
  withXdgEnv(`${xdgRoot}/config`, `${xdgRoot}/data`, () => {
@@ -228,10 +301,9 @@ describe("importHostOpenCode", () => {
228
301
  });
229
302
 
230
303
  it("partial-merge auth: does not overwrite existing credential, adds new one", () => {
231
- // Pre-seed OP_HOME/config/auth.json with one existing credential
232
- const opConfigDir = join(opHome, "config");
233
- mkdirSync(opConfigDir, { recursive: true });
234
- writeFileSync(join(opConfigDir, "auth.json"), JSON.stringify({
304
+ // Pre-seed OP_HOME/knowledge/secrets/auth.json with one existing credential
305
+ mkdirSync(join(opHome, "knowledge", "secrets"), { recursive: true });
306
+ writeFileSync(join(opHome, "knowledge", "secrets", "auth.json"), JSON.stringify({
235
307
  azure: { type: "api", key: "existing" },
236
308
  }));
237
309
 
@@ -252,7 +324,7 @@ describe("importHostOpenCode", () => {
252
324
  });
253
325
 
254
326
  // Verify azure key was NOT overwritten
255
- const written = JSON.parse(readFileSync(join(opConfigDir, "auth.json"), "utf-8")) as Record<string, { key: string }>;
327
+ const written = JSON.parse(readFileSync(join(opHome, "knowledge", "secrets", "auth.json"), "utf-8")) as Record<string, { key: string }>;
256
328
  expect(written.azure.key).toBe("existing");
257
329
  // Verify groq was added
258
330
  expect(written.groq.key).toBe("gsk-host");