@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Verify that Compose `extends` is supported
|
|
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
|
|
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
|
|
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
|
|
33
|
+
// Write custom compose content that uses `extends`
|
|
34
34
|
writeFileSync(
|
|
35
|
-
join(fixtureDir, "stack/
|
|
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/
|
|
57
|
+
expect(existsSync(join(fixtureDir, "stack/custom.compose.yml"))).toBe(true);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test("extends
|
|
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("
|
|
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 (
|
|
6
|
-
* config/stack/ — compose runtime + stack config (stack.env,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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()}/
|
|
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
|
|
45
|
-
return `${resolveOpenPalmHome()}/
|
|
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 `${
|
|
52
|
+
return `${resolveDataDir()}/logs`;
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
export function resolveBackupsDir(): string {
|
|
62
|
-
return `${
|
|
56
|
+
return `${resolveDataDir()}/backups`;
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
export function resolveRegistryDir(): string {
|
|
66
|
-
return `${
|
|
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 `${
|
|
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`, //
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
`${home}/
|
|
98
|
-
`${home}/
|
|
99
|
-
`${home}/cache
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`${home}/state`,
|
|
103
|
-
`${home}/
|
|
104
|
-
`${home}/
|
|
105
|
-
`${home}/
|
|
106
|
-
`${home}/
|
|
107
|
-
`${home}/
|
|
108
|
-
`${home}/
|
|
109
|
-
`${home}/
|
|
110
|
-
|
|
111
|
-
`${home}/
|
|
112
|
-
`${home}/
|
|
113
|
-
`${home}/
|
|
114
|
-
`${home}/
|
|
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
|
|
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, "
|
|
19
|
+
stashDir: join(homeDir, "knowledge"),
|
|
20
20
|
workspaceDir: join(homeDir, "workspace"),
|
|
21
|
-
|
|
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, "
|
|
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, "
|
|
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/
|
|
232
|
-
|
|
233
|
-
|
|
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(
|
|
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");
|