@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
|
@@ -15,9 +15,8 @@ const SKELETON_DIR = join(REPO_ROOT, ".openpalm");
|
|
|
15
15
|
// Allowed top-level dirs in .openpalm/ — mirrors the OP_HOME runtime layout
|
|
16
16
|
const ALLOWED_SOURCE_DIRS = new Set([
|
|
17
17
|
"config", // seed files for config/ (assistant, guardian, stack/, akm/)
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"cache", // empty cache dirs (.gitkeep — regenerable at runtime)
|
|
18
|
+
"knowledge", // knowledge source assets: skills/, env/, secrets/, tasks/
|
|
19
|
+
"data", // empty service dirs (.gitkeep)
|
|
21
20
|
"workspace", // empty workspace dir (.gitkeep)
|
|
22
21
|
]);
|
|
23
22
|
|
|
@@ -37,25 +36,38 @@ describe("skeleton: .openpalm/ top-level directories", () => {
|
|
|
37
36
|
expect(existsSync(join(SKELETON_DIR, "stack"))).toBe(false);
|
|
38
37
|
});
|
|
39
38
|
|
|
40
|
-
test("registry/ no longer exists
|
|
39
|
+
test("registry/ no longer exists", () => {
|
|
41
40
|
expect(existsSync(join(SKELETON_DIR, "registry"))).toBe(false);
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
test("stash-seeds/ no longer exists (moved to
|
|
43
|
+
test("stash-seeds/ no longer exists (moved to knowledge/)", () => {
|
|
45
44
|
expect(existsSync(join(SKELETON_DIR, "stash-seeds"))).toBe(false);
|
|
46
45
|
});
|
|
47
46
|
});
|
|
48
47
|
|
|
48
|
+
// ── power-user helper scripts ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("skeleton: helper scripts", () => {
|
|
51
|
+
test("openpalm.sh and openpalm.ps1 ship at the skeleton root", () => {
|
|
52
|
+
expect(existsSync(join(SKELETON_DIR, "openpalm.sh"))).toBe(true);
|
|
53
|
+
expect(existsSync(join(SKELETON_DIR, "openpalm.ps1"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
49
57
|
// ── config/ subdirectory ──────────────────────────────────────────────
|
|
50
58
|
|
|
51
59
|
describe("skeleton: .openpalm/config/ structure", () => {
|
|
52
|
-
test("config/stack/ exists with
|
|
60
|
+
test("config/stack/ exists with fixed compose files (no stack.yml)", () => {
|
|
53
61
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
|
|
54
|
-
expect(existsSync(join(SKELETON_DIR, "config", "stack", "
|
|
62
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "services.compose.yml"))).toBe(true);
|
|
63
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "channels.compose.yml"))).toBe(true);
|
|
64
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "custom.compose.yml"))).toBe(true);
|
|
65
|
+
// stack.yml removed in 0.11.0 — addon enablement lives in stack.env.
|
|
66
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(false);
|
|
55
67
|
});
|
|
56
68
|
|
|
57
|
-
test("config/stack/addons/
|
|
58
|
-
expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(
|
|
69
|
+
test("config/stack/addons/ does not exist", () => {
|
|
70
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(false);
|
|
59
71
|
});
|
|
60
72
|
|
|
61
73
|
test("config/akm/ exists", () => {
|
|
@@ -63,86 +75,84 @@ describe("skeleton: .openpalm/config/ structure", () => {
|
|
|
63
75
|
});
|
|
64
76
|
|
|
65
77
|
test("config/assistant/ has seed files", () => {
|
|
66
|
-
expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.
|
|
78
|
+
expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.jsonc"))).toBe(true);
|
|
67
79
|
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ── state/registry/ subdirectory ─────────────────────────────────────
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const addonsDir = join(SKELETON_DIR, "state", "registry", "addons");
|
|
75
|
-
expect(existsSync(addonsDir)).toBe(true);
|
|
76
|
-
const addons = readdirSync(addonsDir);
|
|
77
|
-
expect(addons).toContain("chat");
|
|
78
|
-
expect(addons).toContain("api");
|
|
79
|
-
expect(addons).toContain("discord");
|
|
81
|
+
test("config/guardian/ has the OpenCode global config (mounted at /etc/opencode)", () => {
|
|
82
|
+
expect(existsSync(join(SKELETON_DIR, "config", "guardian", "opencode.jsonc"))).toBe(true);
|
|
80
83
|
});
|
|
81
84
|
|
|
82
|
-
test("
|
|
83
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
85
|
+
test("config/guardian/ ships the message-moderation instructions", () => {
|
|
86
|
+
expect(existsSync(join(SKELETON_DIR, "config", "guardian", "instructions", "moderation.md"))).toBe(true);
|
|
84
87
|
});
|
|
88
|
+
});
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
for (const addon of addons) {
|
|
92
|
-
expect(existsSync(join(addonsDir, addon, "compose.yml"))).toBe(true);
|
|
93
|
-
}
|
|
90
|
+
// ── no runtime registry ───────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("skeleton: no runtime registry", () => {
|
|
93
|
+
test("data/registry/ does not exist", () => {
|
|
94
|
+
expect(existsSync(join(SKELETON_DIR, "data", "registry"))).toBe(false);
|
|
94
95
|
});
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
// ──
|
|
98
|
+
// ── knowledge/ subdirectory ───────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("skeleton: .openpalm/knowledge/ structure", () => {
|
|
101
|
+
test("knowledge/skills/ exists with config-diagnostics skill", () => {
|
|
102
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
|
|
103
|
+
});
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(existsSync(join(SKELETON_DIR, "stash", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
|
|
105
|
+
test("knowledge/env/ exists with user.env seed", () => {
|
|
106
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "env", "user.env"))).toBe(true);
|
|
102
107
|
});
|
|
103
108
|
|
|
104
|
-
test("
|
|
105
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
109
|
+
test("knowledge/secrets/ exists", () => {
|
|
110
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "secrets"))).toBe(true);
|
|
106
111
|
});
|
|
107
112
|
|
|
108
|
-
test("
|
|
109
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
113
|
+
test("knowledge/tasks/ exists", () => {
|
|
114
|
+
expect(existsSync(join(SKELETON_DIR, "knowledge", "tasks"))).toBe(true);
|
|
110
115
|
});
|
|
111
116
|
});
|
|
112
117
|
|
|
113
|
-
// ──
|
|
118
|
+
// ── data/ service dirs ────────────────────────────────────────────────
|
|
114
119
|
|
|
115
|
-
describe("skeleton: .openpalm/
|
|
116
|
-
const serviceDirs = ["assistant", "
|
|
120
|
+
describe("skeleton: .openpalm/data/ service directories", () => {
|
|
121
|
+
const serviceDirs = ["assistant", "guardian"];
|
|
117
122
|
|
|
118
123
|
for (const dir of serviceDirs) {
|
|
119
|
-
test(`
|
|
120
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
124
|
+
test(`data/${dir}/ exists`, () => {
|
|
125
|
+
expect(existsSync(join(SKELETON_DIR, "data", dir))).toBe(true);
|
|
121
126
|
});
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
test("
|
|
125
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
129
|
+
test("data/akm/ exists", () => {
|
|
130
|
+
expect(existsSync(join(SKELETON_DIR, "data", "akm"))).toBe(true);
|
|
126
131
|
});
|
|
127
132
|
|
|
128
|
-
test("
|
|
129
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
133
|
+
test("data/akm/cache and data/akm/data exist", () => {
|
|
134
|
+
expect(existsSync(join(SKELETON_DIR, "data", "akm", "cache"))).toBe(true);
|
|
135
|
+
expect(existsSync(join(SKELETON_DIR, "data", "akm", "data"))).toBe(true);
|
|
130
136
|
});
|
|
131
137
|
|
|
132
|
-
test("
|
|
133
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
138
|
+
test("data/logs/ exists", () => {
|
|
139
|
+
expect(existsSync(join(SKELETON_DIR, "data", "logs"))).toBe(true);
|
|
134
140
|
});
|
|
135
141
|
});
|
|
136
142
|
|
|
137
|
-
// ──
|
|
143
|
+
// ── data/rollback and workspace/ ──────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
describe("skeleton: .openpalm/data/rollback and workspace/", () => {
|
|
146
|
+
test("cache/ does not exist in the skeleton", () => {
|
|
147
|
+
expect(existsSync(join(SKELETON_DIR, "cache"))).toBe(false);
|
|
148
|
+
});
|
|
138
149
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
expect(existsSync(join(SKELETON_DIR, "cache", "akm"))).toBe(true);
|
|
150
|
+
test("data/backups/ exists", () => {
|
|
151
|
+
expect(existsSync(join(SKELETON_DIR, "data", "backups"))).toBe(true);
|
|
142
152
|
});
|
|
143
153
|
|
|
144
|
-
test("
|
|
145
|
-
expect(existsSync(join(SKELETON_DIR, "
|
|
154
|
+
test("data/rollback/ exists", () => {
|
|
155
|
+
expect(existsSync(join(SKELETON_DIR, "data", "rollback"))).toBe(true);
|
|
146
156
|
});
|
|
147
157
|
|
|
148
158
|
test("workspace/ exists", () => {
|
|
@@ -4,8 +4,6 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { deriveSystemEnvFromSpec, writeVoiceVars } from "./spec-to-env.js";
|
|
6
6
|
|
|
7
|
-
const MINIMAL_SPEC = { version: 2 as const };
|
|
8
|
-
|
|
9
7
|
let tempDir = "";
|
|
10
8
|
|
|
11
9
|
beforeEach(() => {
|
|
@@ -18,34 +16,41 @@ afterEach(() => {
|
|
|
18
16
|
|
|
19
17
|
describe("deriveSystemEnvFromSpec", () => {
|
|
20
18
|
test("produces OP_HOME", () => {
|
|
21
|
-
const result = deriveSystemEnvFromSpec(
|
|
19
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
22
20
|
expect(result.OP_HOME).toBe("/home/op");
|
|
23
21
|
});
|
|
24
22
|
|
|
25
23
|
test("produces default port values", () => {
|
|
26
|
-
const result = deriveSystemEnvFromSpec(
|
|
24
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
27
25
|
expect(result.OP_ASSISTANT_PORT).toBe("3800");
|
|
26
|
+
expect(result.OP_HOST_UI_PORT).toBe("3880");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("does not emit the retired OP_ADMIN_PORT/OP_ADMIN_OPENCODE_PORT vars", () => {
|
|
30
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
31
|
+
expect(result.OP_ADMIN_PORT).toBeUndefined();
|
|
32
|
+
expect(result.OP_ADMIN_OPENCODE_PORT).toBeUndefined();
|
|
28
33
|
});
|
|
29
34
|
|
|
30
35
|
test("does not emit OP_GUARDIAN_PORT (guardian is network-only, no host mapping)", () => {
|
|
31
|
-
const result = deriveSystemEnvFromSpec(
|
|
36
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
32
37
|
expect(result.OP_GUARDIAN_PORT).toBeUndefined();
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
test("does not include the retired memory service port", () => {
|
|
36
|
-
const result = deriveSystemEnvFromSpec(
|
|
41
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
37
42
|
const retired = "OP_" + "MEMORY_PORT";
|
|
38
43
|
expect(result[retired]).toBeUndefined();
|
|
39
44
|
});
|
|
40
45
|
|
|
41
46
|
test("does not include LLM provider in system env", () => {
|
|
42
|
-
const result = deriveSystemEnvFromSpec(
|
|
47
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
43
48
|
expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
|
|
44
49
|
expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
|
|
45
50
|
});
|
|
46
51
|
|
|
47
52
|
test("does not include removed feature flags", () => {
|
|
48
|
-
const result = deriveSystemEnvFromSpec(
|
|
53
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
49
54
|
expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
|
|
50
55
|
expect(result.OP_ADMIN_ENABLED).toBeUndefined();
|
|
51
56
|
});
|
|
@@ -57,61 +62,93 @@ describe("deriveSystemEnvFromSpec", () => {
|
|
|
57
62
|
// hard-coded constant.
|
|
58
63
|
if (process.platform === "win32") return;
|
|
59
64
|
const expected = statSync(tempDir);
|
|
60
|
-
const result = deriveSystemEnvFromSpec(
|
|
65
|
+
const result = deriveSystemEnvFromSpec(tempDir);
|
|
61
66
|
expect(result.OP_UID).toBe(String(expected.uid));
|
|
62
67
|
expect(result.OP_GID).toBe(String(expected.gid));
|
|
63
68
|
});
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
describe("writeVoiceVars", () => {
|
|
72
|
+
// writeVoiceVars takes a stackDir (<home>/config/stack) and writes the env
|
|
73
|
+
// to <home>/knowledge/env/stack.env. Build that layout per test.
|
|
74
|
+
let stackDir = "";
|
|
75
|
+
let stackEnv = "";
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
stackDir = join(tempDir, "config", "stack");
|
|
78
|
+
stackEnv = join(tempDir, "knowledge", "env", "stack.env");
|
|
79
|
+
mkdirSync(stackDir, { recursive: true });
|
|
80
|
+
mkdirSync(join(tempDir, "knowledge", "env"), { recursive: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
67
83
|
test("writes TTS vars to stack.env", () => {
|
|
68
|
-
|
|
69
|
-
writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
|
|
84
|
+
writeFileSync(stackEnv, "# stack env\n");
|
|
70
85
|
|
|
71
86
|
writeVoiceVars({
|
|
72
87
|
tts: { baseURL: "https://tts.example.com/v1", model: "tts-1", voice: "alloy" },
|
|
73
|
-
},
|
|
88
|
+
}, stackDir);
|
|
74
89
|
|
|
75
|
-
const content = readFileSync(
|
|
90
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
76
91
|
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
77
92
|
expect(content).toContain("OP_TTS_MODEL=tts-1");
|
|
78
93
|
expect(content).toContain("OP_TTS_VOICE=alloy");
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
test("writes STT vars to stack.env", () => {
|
|
82
|
-
|
|
83
|
-
writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
|
|
97
|
+
writeFileSync(stackEnv, "# stack env\n");
|
|
84
98
|
|
|
85
99
|
writeVoiceVars({
|
|
86
100
|
stt: { baseURL: "https://stt.example.com/v1", model: "whisper-1", language: "en" },
|
|
87
|
-
},
|
|
101
|
+
}, stackDir);
|
|
88
102
|
|
|
89
|
-
const content = readFileSync(
|
|
103
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
90
104
|
expect(content).toContain("OP_STT_BASE_URL=https://stt.example.com/v1");
|
|
91
105
|
expect(content).toContain("OP_STT_MODEL=whisper-1");
|
|
92
106
|
expect(content).toContain("OP_STT_LANGUAGE=en");
|
|
93
107
|
});
|
|
94
108
|
|
|
95
109
|
test("creates stack.env if it does not exist", () => {
|
|
96
|
-
mkdirSync(tempDir, { recursive: true });
|
|
97
|
-
|
|
98
110
|
writeVoiceVars({
|
|
99
111
|
tts: { baseURL: "https://tts.example.com/v1", model: "tts-1" },
|
|
100
|
-
},
|
|
112
|
+
}, stackDir);
|
|
101
113
|
|
|
102
|
-
const content = readFileSync(
|
|
114
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
103
115
|
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
104
116
|
});
|
|
105
117
|
|
|
106
118
|
test("is a no-op when no vars are provided", () => {
|
|
107
|
-
|
|
108
|
-
const stackEnvPath = join(tempDir, "stack.env");
|
|
109
|
-
writeFileSync(stackEnvPath, "EXISTING=value\n");
|
|
119
|
+
writeFileSync(stackEnv, "EXISTING=value\n");
|
|
110
120
|
|
|
111
|
-
writeVoiceVars({},
|
|
121
|
+
writeVoiceVars({}, stackDir);
|
|
112
122
|
|
|
113
123
|
// File should be unchanged
|
|
114
|
-
const content = readFileSync(
|
|
124
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
115
125
|
expect(content).toBe("EXISTING=value\n");
|
|
116
126
|
});
|
|
127
|
+
|
|
128
|
+
test("auto-fills baseURL/model/voice for openpalm-voice engine (setup wizard path)", () => {
|
|
129
|
+
// Reproduces the bug: setup wizard sends engine only, no baseURL.
|
|
130
|
+
// writeVoiceVars must fill in OP_TTS_BASE_URL / OP_STT_BASE_URL.
|
|
131
|
+
writeVoiceVars({
|
|
132
|
+
tts: { engine: "openpalm-voice" },
|
|
133
|
+
stt: { engine: "openpalm-voice" },
|
|
134
|
+
}, stackDir);
|
|
135
|
+
|
|
136
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
137
|
+
expect(content).toContain("OP_TTS_ENGINE=openpalm-voice");
|
|
138
|
+
expect(content).toMatch(/OP_TTS_BASE_URL=http:\/\/127\.0\.0\.1:\d+/);
|
|
139
|
+
expect(content).toContain("OP_TTS_MODEL=kokoro");
|
|
140
|
+
expect(content).toContain("OP_TTS_VOICE=bf_isabella");
|
|
141
|
+
expect(content).toContain("OP_STT_ENGINE=openpalm-voice");
|
|
142
|
+
expect(content).toMatch(/OP_STT_BASE_URL=http:\/\/127\.0\.0\.1:\d+/);
|
|
143
|
+
expect(content).toContain("OP_STT_MODEL=whisper-1");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("does not overwrite explicit baseURL when engine is openpalm-voice", () => {
|
|
147
|
+
writeVoiceVars({
|
|
148
|
+
tts: { engine: "openpalm-voice", baseURL: "http://192.168.1.50:8880" },
|
|
149
|
+
}, stackDir);
|
|
150
|
+
|
|
151
|
+
const content = readFileSync(stackEnv, "utf-8");
|
|
152
|
+
expect(content).toContain("OP_TTS_BASE_URL=http://192.168.1.50:8880");
|
|
153
|
+
});
|
|
117
154
|
});
|
|
@@ -5,20 +5,19 @@
|
|
|
5
5
|
* Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { SPEC_DEFAULTS } from "./defaults.js";
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
11
|
import { mergeEnvContent } from "./env.js";
|
|
12
12
|
import { resolveOperatorIds } from "./operator-ids.js";
|
|
13
|
+
import { assertNoSecretLikeStackEnvKeys } from './secrets.js';
|
|
14
|
+
import { stackEnvPathFromStackDir } from './paths.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
* Derive the system.env key-value pairs from the
|
|
17
|
+
* Derive the system.env key-value pairs from the setup spec + defaults.
|
|
16
18
|
* Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
|
|
17
19
|
*/
|
|
18
|
-
export function deriveSystemEnvFromSpec(
|
|
19
|
-
spec: StackSpec,
|
|
20
|
-
homeDir: string,
|
|
21
|
-
): Record<string, string> {
|
|
20
|
+
export function deriveSystemEnvFromSpec(homeDir: string): Record<string, string> {
|
|
22
21
|
const ports = SPEC_DEFAULTS.ports;
|
|
23
22
|
const image = SPEC_DEFAULTS.image;
|
|
24
23
|
|
|
@@ -43,12 +42,9 @@ export function deriveSystemEnvFromSpec(
|
|
|
43
42
|
// network-only (no host port mapping) so OP_GUARDIAN_PORT is no longer
|
|
44
43
|
// emitted; channels reach it via Docker DNS at http://guardian:8080.
|
|
45
44
|
result["OP_ASSISTANT_PORT"] = String(ports.assistant);
|
|
46
|
-
result["
|
|
47
|
-
result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
|
|
45
|
+
result["OP_HOST_UI_PORT"] = String(ports.hostUi);
|
|
48
46
|
result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
|
|
49
47
|
|
|
50
|
-
void spec; // spec reserved for future use; ports/image come from SPEC_DEFAULTS
|
|
51
|
-
|
|
52
48
|
return result;
|
|
53
49
|
}
|
|
54
50
|
|
|
@@ -75,13 +71,47 @@ export type VoiceVarsConfig = {
|
|
|
75
71
|
};
|
|
76
72
|
};
|
|
77
73
|
|
|
74
|
+
// Authoritative defaults for the bundled openpalm/voice addon.
|
|
75
|
+
const OPENPALM_VOICE_TTS_MODEL = "kokoro";
|
|
76
|
+
const OPENPALM_VOICE_STT_MODEL = "whisper-1";
|
|
77
|
+
const OPENPALM_VOICE_DEFAULT_VOICE = "bf_isabella";
|
|
78
|
+
|
|
79
|
+
function openpalmVoiceBaseURL(): string {
|
|
80
|
+
const raw = (process.env["OP_VOICE_PORT_HOST"] ?? "").trim();
|
|
81
|
+
const n = raw ? Number(raw) : NaN;
|
|
82
|
+
const port = Number.isFinite(n) && n > 0 ? n : 8880;
|
|
83
|
+
return `http://127.0.0.1:${port}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* For `engine === 'openpalm-voice'`, fill in baseURL/model/voice with the
|
|
88
|
+
* addon's preset defaults when not already provided by the caller.
|
|
89
|
+
* Mutates the section in place.
|
|
90
|
+
*/
|
|
91
|
+
function applyOpenPalmVoicePreset(
|
|
92
|
+
section: NonNullable<VoiceVarsConfig["tts"]> | NonNullable<VoiceVarsConfig["stt"]>,
|
|
93
|
+
kind: "tts" | "stt"
|
|
94
|
+
): void {
|
|
95
|
+
if (section.engine !== "openpalm-voice") return;
|
|
96
|
+
if (!section.baseURL?.trim()) section.baseURL = openpalmVoiceBaseURL();
|
|
97
|
+
if (!section.model?.trim()) {
|
|
98
|
+
section.model = kind === "tts" ? OPENPALM_VOICE_TTS_MODEL : OPENPALM_VOICE_STT_MODEL;
|
|
99
|
+
}
|
|
100
|
+
if (kind === "tts" && !(section as NonNullable<VoiceVarsConfig["tts"]>).voice?.trim()) {
|
|
101
|
+
(section as NonNullable<VoiceVarsConfig["tts"]>).voice = OPENPALM_VOICE_DEFAULT_VOICE;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
/**
|
|
79
106
|
* Write TTS/STT env vars to stack.env for the voice channel container.
|
|
80
107
|
* `engine` always writes (even if it's the only field) so picking an
|
|
81
108
|
* engine without filling in URL/model still persists.
|
|
109
|
+
* For `engine === 'openpalm-voice'`, missing baseURL/model/voice are
|
|
110
|
+
* auto-filled from the addon's preset so setup wizard callers don't need
|
|
111
|
+
* to know the defaults.
|
|
82
112
|
*/
|
|
83
113
|
export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
|
|
84
|
-
const stackEnvPath =
|
|
114
|
+
const stackEnvPath = stackEnvPathFromStackDir(stackDir);
|
|
85
115
|
const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
86
116
|
const vars: Record<string, string> = {};
|
|
87
117
|
|
|
@@ -90,7 +120,12 @@ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void
|
|
|
90
120
|
// operator shells. The UI server only reads OP_-prefixed vars from
|
|
91
121
|
// process.env, so a leaked host TTS_VOICE can't silently override the
|
|
92
122
|
// saved selection.
|
|
93
|
-
const { tts
|
|
123
|
+
const tts = config.tts ? { ...config.tts } : undefined;
|
|
124
|
+
const stt = config.stt ? { ...config.stt } : undefined;
|
|
125
|
+
|
|
126
|
+
if (tts) applyOpenPalmVoicePreset(tts, "tts");
|
|
127
|
+
if (stt) applyOpenPalmVoicePreset(stt, "stt");
|
|
128
|
+
|
|
94
129
|
if (tts?.enabled !== false) {
|
|
95
130
|
if (tts?.engine) vars["OP_TTS_ENGINE"] = tts.engine;
|
|
96
131
|
if (tts?.provider) vars["OP_TTS_PROVIDER"] = tts.provider;
|
|
@@ -107,10 +142,12 @@ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void
|
|
|
107
142
|
}
|
|
108
143
|
|
|
109
144
|
if (Object.keys(vars).length === 0) return;
|
|
145
|
+
assertNoSecretLikeStackEnvKeys(vars);
|
|
110
146
|
|
|
111
147
|
let content = mergeEnvContent(base, vars, {
|
|
112
148
|
sectionHeader: "# ── Voice Channel (TTS/STT) ──────────────────────────────────────────",
|
|
113
149
|
});
|
|
114
150
|
if (!content.endsWith("\n")) content += "\n";
|
|
151
|
+
mkdirSync(dirname(stackEnvPath), { recursive: true, mode: 0o700 });
|
|
115
152
|
writeFileSync(stackEnvPath, content, { mode: 0o600 });
|
|
116
153
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
resolveTasksDir, listTaskFiles, readTaskFile, writeTaskFile, removeTaskFile, assertSafeTaskFilename,
|
|
7
|
+
} from './task-files.js';
|
|
8
|
+
|
|
9
|
+
let stashDir = '';
|
|
10
|
+
|
|
11
|
+
beforeEach(() => { stashDir = mkdtempSync(join(tmpdir(), 'op-tasks-')); });
|
|
12
|
+
afterEach(() => { rmSync(stashDir, { recursive: true, force: true }); });
|
|
13
|
+
|
|
14
|
+
describe('task-files', () => {
|
|
15
|
+
it('lists only .yml/.yaml/.md files with sizes', () => {
|
|
16
|
+
const dir = resolveTasksDir(stashDir);
|
|
17
|
+
writeFileSync(join(dir, 'health-check.yml'), 'enabled: false\n');
|
|
18
|
+
writeFileSync(join(dir, 'notes.md'), '# notes\n');
|
|
19
|
+
writeFileSync(join(dir, 'ignore.txt'), 'x');
|
|
20
|
+
const files = listTaskFiles(stashDir);
|
|
21
|
+
const names = files.map((f) => f.name);
|
|
22
|
+
expect(names).toContain('health-check.yml');
|
|
23
|
+
expect(names).toContain('notes.md');
|
|
24
|
+
expect(names).not.toContain('ignore.txt');
|
|
25
|
+
expect(files.find((f) => f.name === 'health-check.yml')!.size).toBe('enabled: false\n'.length);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reads, writes (0644), and removes a task file', () => {
|
|
29
|
+
writeTaskFile(stashDir, 'my-task.yml', "schedule: '0 9 * * *'\nenabled: true\n");
|
|
30
|
+
expect(statSync(join(resolveTasksDir(stashDir), 'my-task.yml')).mode & 0o777).toBe(0o644);
|
|
31
|
+
expect(readTaskFile(stashDir, 'my-task.yml')).toContain('enabled: true');
|
|
32
|
+
removeTaskFile(stashDir, 'my-task.yml');
|
|
33
|
+
expect(readTaskFile(stashDir, 'my-task.yml')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects traversal and non-task extensions', () => {
|
|
37
|
+
expect(() => assertSafeTaskFilename('../escape.yml')).toThrow();
|
|
38
|
+
expect(() => assertSafeTaskFilename('a/b.yml')).toThrow();
|
|
39
|
+
expect(() => assertSafeTaskFilename('secrets.txt')).toThrow();
|
|
40
|
+
expect(() => assertSafeTaskFilename('config.json')).toThrow();
|
|
41
|
+
expect(() => assertSafeTaskFilename('ok.yml')).not.toThrow();
|
|
42
|
+
expect(() => assertSafeTaskFilename('ok.yaml')).not.toThrow();
|
|
43
|
+
expect(() => assertSafeTaskFilename('ok.md')).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw file access for the Automations admin tab — a plain editor for the akm
|
|
3
|
+
* task files in the assistant tasks dir (/stash/tasks = knowledge/tasks).
|
|
4
|
+
*
|
|
5
|
+
* akm task files are YAML (`.yml`/`.yaml`) or markdown (`.md`). Names are always
|
|
6
|
+
* basenames within the tasks dir; the guard rejects path separators and `..`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const TASK_FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.(ya?ml|md)$/;
|
|
12
|
+
|
|
13
|
+
export function assertSafeTaskFilename(name: string): void {
|
|
14
|
+
if (!TASK_FILENAME_RE.test(name) || name.includes('..')) {
|
|
15
|
+
throw new Error(`Invalid task file name: ${name} (expected a .yml/.yaml/.md basename)`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The assistant tasks dir for a given stash dir (knowledge). Created if absent. */
|
|
20
|
+
export function resolveTasksDir(stashDir: string): string {
|
|
21
|
+
const dir = join(stashDir, 'tasks');
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TaskFileInfo = { name: string; size: number };
|
|
27
|
+
|
|
28
|
+
/** List the task files (.yml/.yaml/.md) in the tasks dir, with byte sizes. */
|
|
29
|
+
export function listTaskFiles(stashDir: string): TaskFileInfo[] {
|
|
30
|
+
const dir = resolveTasksDir(stashDir);
|
|
31
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
32
|
+
.filter((e) => e.isFile() && TASK_FILENAME_RE.test(e.name) && !e.name.includes('..'))
|
|
33
|
+
.map((e) => ({ name: e.name, size: statSync(join(dir, e.name)).size }))
|
|
34
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readTaskFile(stashDir: string, name: string): string | null {
|
|
38
|
+
assertSafeTaskFilename(name);
|
|
39
|
+
const path = join(resolveTasksDir(stashDir), name);
|
|
40
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeTaskFile(stashDir: string, name: string, content: string): void {
|
|
44
|
+
assertSafeTaskFilename(name);
|
|
45
|
+
writeFileSync(join(resolveTasksDir(stashDir), name), content, { mode: 0o644 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function removeTaskFile(stashDir: string, name: string): void {
|
|
49
|
+
assertSafeTaskFilename(name);
|
|
50
|
+
rmSync(join(resolveTasksDir(stashDir), name), { force: true });
|
|
51
|
+
}
|
|
@@ -27,10 +27,9 @@ export type ArtifactMeta = {
|
|
|
27
27
|
export type ControlPlaneState = {
|
|
28
28
|
homeDir: string;
|
|
29
29
|
configDir: string;
|
|
30
|
-
stashDir: string; // homeDir/
|
|
30
|
+
stashDir: string; // homeDir/knowledge
|
|
31
31
|
workspaceDir: string; // homeDir/workspace
|
|
32
|
-
|
|
33
|
-
stateDir: string; // homeDir/state (service data + system state)
|
|
32
|
+
dataDir: string; // homeDir/data (service data + operational files)
|
|
34
33
|
stackDir: string; // configDir/stack (compose runtime + stack config)
|
|
35
34
|
services: Record<string, "running" | "stopped">;
|
|
36
35
|
artifacts: {
|
|
@@ -48,4 +47,3 @@ export const CORE_SERVICES: CoreServiceName[] = [
|
|
|
48
47
|
"assistant",
|
|
49
48
|
"guardian",
|
|
50
49
|
];
|
|
51
|
-
|