@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.
- 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 +69 -30
- package/src/control-plane/compose-args.ts +62 -8
- package/src/control-plane/config-persistence.ts +102 -136
- package/src/control-plane/core-assets.ts +45 -60
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +16 -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 +100 -136
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +45 -40
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- 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 +301 -110
- 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 +137 -61
- package/src/control-plane/setup.ts +82 -63
- package/src/control-plane/skeleton-guardrail.test.ts +66 -56
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +51 -14
- 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 +333 -0
- package/src/control-plane/ui-assets.ts +290 -142
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +96 -26
- 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
- package/src/control-plane/stack-spec.test.ts +0 -94
- 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
|
|
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
|
|
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
|
|
90
|
-
const result = mergeEnvContent(input, {
|
|
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.
|
|
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
|
|
97
|
-
const result = mergeEnvContent(input, {
|
|
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.
|
|
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
|
|
104
|
-
const result = mergeEnvContent(input, {
|
|
105
|
-
expect(result).toMatch(/^export
|
|
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.
|
|
107
|
+
expect(parsed.TEST_VALUE).toBe("new#value");
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
package/src/control-plane/env.ts
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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()}/
|
|
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
|
+
}
|