@openpalm/lib 0.11.0-beta.8 → 0.11.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. package/src/control-plane/registry-components.test.ts +0 -391
@@ -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
- "stash", // stash source assets: skills/ and vaults/
19
- "state", // state/registry/ + empty service dirs (.gitkeep)
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,37 @@ 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 (moved to state/registry/)", () => {
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 stash/)", () => {
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 core.compose.yml and stack.yml", () => {
60
+ test("config/stack/ exists with fixed compose files and stack.yml", () => {
53
61
  expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
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);
54
65
  expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(true);
55
66
  });
56
67
 
57
- test("config/stack/addons/ exists", () => {
58
- expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(true);
68
+ test("config/stack/addons/ does not exist", () => {
69
+ expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(false);
59
70
  });
60
71
 
61
72
  test("config/akm/ exists", () => {
@@ -63,86 +74,84 @@ describe("skeleton: .openpalm/config/ structure", () => {
63
74
  });
64
75
 
65
76
  test("config/assistant/ has seed files", () => {
66
- expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.json"))).toBe(true);
77
+ expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.jsonc"))).toBe(true);
67
78
  });
68
- });
69
-
70
- // ── state/registry/ subdirectory ─────────────────────────────────────
71
79
 
72
- describe("skeleton: .openpalm/state/registry/ structure", () => {
73
- test("state/registry/addons/ exists with addon subdirectories", () => {
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");
80
+ test("config/guardian/ has the OpenCode global config (mounted at /etc/opencode)", () => {
81
+ expect(existsSync(join(SKELETON_DIR, "config", "guardian", "opencode.jsonc"))).toBe(true);
80
82
  });
81
83
 
82
- test("state/registry/automations/ exists", () => {
83
- expect(existsSync(join(SKELETON_DIR, "state", "registry", "automations"))).toBe(true);
84
+ test("config/guardian/ ships the message-moderation instructions", () => {
85
+ expect(existsSync(join(SKELETON_DIR, "config", "guardian", "instructions", "moderation.md"))).toBe(true);
84
86
  });
87
+ });
85
88
 
86
- test("each addon has compose.yml", () => {
87
- const addonsDir = join(SKELETON_DIR, "state", "registry", "addons");
88
- const addons = readdirSync(addonsDir).filter(e => {
89
- try { return statSync(join(addonsDir, e)).isDirectory(); } catch { return false; }
90
- });
91
- for (const addon of addons) {
92
- expect(existsSync(join(addonsDir, addon, "compose.yml"))).toBe(true);
93
- }
89
+ // ── no runtime registry ───────────────────────────────────────────────
90
+
91
+ describe("skeleton: no runtime registry", () => {
92
+ test("data/registry/ does not exist", () => {
93
+ expect(existsSync(join(SKELETON_DIR, "data", "registry"))).toBe(false);
94
94
  });
95
95
  });
96
96
 
97
- // ── stash/ subdirectory ───────────────────────────────────────────────
97
+ // ── knowledge/ subdirectory ───────────────────────────────────────────────
98
+
99
+ describe("skeleton: .openpalm/knowledge/ structure", () => {
100
+ test("knowledge/skills/ exists with config-diagnostics skill", () => {
101
+ expect(existsSync(join(SKELETON_DIR, "knowledge", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
102
+ });
98
103
 
99
- describe("skeleton: .openpalm/stash/ structure", () => {
100
- test("stash/skills/ exists with config-diagnostics skill", () => {
101
- expect(existsSync(join(SKELETON_DIR, "stash", "skills", "config-diagnostics", "SKILL.md"))).toBe(true);
104
+ test("knowledge/env/ exists with user.env seed", () => {
105
+ expect(existsSync(join(SKELETON_DIR, "knowledge", "env", "user.env"))).toBe(true);
102
106
  });
103
107
 
104
- test("stash/vaults/ exists", () => {
105
- expect(existsSync(join(SKELETON_DIR, "stash", "vaults"))).toBe(true);
108
+ test("knowledge/secrets/ exists", () => {
109
+ expect(existsSync(join(SKELETON_DIR, "knowledge", "secrets"))).toBe(true);
106
110
  });
107
111
 
108
- test("stash/tasks/ exists", () => {
109
- expect(existsSync(join(SKELETON_DIR, "stash", "tasks"))).toBe(true);
112
+ test("knowledge/tasks/ exists", () => {
113
+ expect(existsSync(join(SKELETON_DIR, "knowledge", "tasks"))).toBe(true);
110
114
  });
111
115
  });
112
116
 
113
- // ── state/ service dirs ───────────────────────────────────────────────
117
+ // ── data/ service dirs ────────────────────────────────────────────────
114
118
 
115
- describe("skeleton: .openpalm/state/ service directories", () => {
116
- const serviceDirs = ["assistant", "admin", "guardian", "logs", "backups"];
119
+ describe("skeleton: .openpalm/data/ service directories", () => {
120
+ const serviceDirs = ["assistant", "guardian"];
117
121
 
118
122
  for (const dir of serviceDirs) {
119
- test(`state/${dir}/ exists`, () => {
120
- expect(existsSync(join(SKELETON_DIR, "state", dir))).toBe(true);
123
+ test(`data/${dir}/ exists`, () => {
124
+ expect(existsSync(join(SKELETON_DIR, "data", dir))).toBe(true);
121
125
  });
122
126
  }
123
127
 
124
- test("state/akm/data/ exists", () => {
125
- expect(existsSync(join(SKELETON_DIR, "state", "akm", "data"))).toBe(true);
128
+ test("data/akm/ exists", () => {
129
+ expect(existsSync(join(SKELETON_DIR, "data", "akm"))).toBe(true);
126
130
  });
127
131
 
128
- test("state/akm/state/ exists", () => {
129
- expect(existsSync(join(SKELETON_DIR, "state", "akm", "state"))).toBe(true);
132
+ test("data/akm/cache and data/akm/data exist", () => {
133
+ expect(existsSync(join(SKELETON_DIR, "data", "akm", "cache"))).toBe(true);
134
+ expect(existsSync(join(SKELETON_DIR, "data", "akm", "data"))).toBe(true);
130
135
  });
131
136
 
132
- test("state/logs/opencode/ exists", () => {
133
- expect(existsSync(join(SKELETON_DIR, "state", "logs", "opencode"))).toBe(true);
137
+ test("data/logs/ exists", () => {
138
+ expect(existsSync(join(SKELETON_DIR, "data", "logs"))).toBe(true);
134
139
  });
135
140
  });
136
141
 
137
- // ── cache/ and workspace/ ─────────────────────────────────────────────
142
+ // ── data/rollback and workspace/ ──────────────────────────────────────
143
+
144
+ describe("skeleton: .openpalm/data/rollback and workspace/", () => {
145
+ test("cache/ does not exist in the skeleton", () => {
146
+ expect(existsSync(join(SKELETON_DIR, "cache"))).toBe(false);
147
+ });
138
148
 
139
- describe("skeleton: .openpalm/cache/ and workspace/", () => {
140
- test("cache/akm/ exists", () => {
141
- expect(existsSync(join(SKELETON_DIR, "cache", "akm"))).toBe(true);
149
+ test("data/backups/ exists", () => {
150
+ expect(existsSync(join(SKELETON_DIR, "data", "backups"))).toBe(true);
142
151
  });
143
152
 
144
- test("cache/rollback/ exists", () => {
145
- expect(existsSync(join(SKELETON_DIR, "cache", "rollback"))).toBe(true);
153
+ test("data/rollback/ exists", () => {
154
+ expect(existsSync(join(SKELETON_DIR, "data", "rollback"))).toBe(true);
146
155
  });
147
156
 
148
157
  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(MINIMAL_SPEC, "/home/op");
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(MINIMAL_SPEC, "/home/op");
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(MINIMAL_SPEC, "/home/op");
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(MINIMAL_SPEC, "/home/op");
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(MINIMAL_SPEC, "/home/op");
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(MINIMAL_SPEC, "/home/op");
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(MINIMAL_SPEC, tempDir);
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
- mkdirSync(tempDir, { recursive: true });
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
- }, tempDir);
88
+ }, stackDir);
74
89
 
75
- const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
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
- mkdirSync(tempDir, { recursive: true });
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
- }, tempDir);
101
+ }, stackDir);
88
102
 
89
- const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
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
- }, tempDir);
112
+ }, stackDir);
101
113
 
102
- const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
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
- mkdirSync(tempDir, { recursive: true });
108
- const stackEnvPath = join(tempDir, "stack.env");
109
- writeFileSync(stackEnvPath, "EXISTING=value\n");
119
+ writeFileSync(stackEnv, "EXISTING=value\n");
110
120
 
111
- writeVoiceVars({}, tempDir);
121
+ writeVoiceVars({}, stackDir);
112
122
 
113
123
  // File should be unchanged
114
- const content = readFileSync(stackEnvPath, "utf-8");
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 type { StackSpec } from "./stack-spec.js";
9
8
  import { SPEC_DEFAULTS } from "./stack-spec.js";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
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
17
  * Derive the system.env key-value pairs from the StackSpec.
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["OP_ADMIN_PORT"] = String(ports.admin);
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 = `${stackDir}/stack.env`;
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, stt } = config;
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
  }
@@ -11,7 +11,6 @@ import {
11
11
  readStackSpec,
12
12
  writeStackSpec,
13
13
  STACK_SPEC_FILENAME,
14
- stackSpecPath,
15
14
  } from "./stack-spec.js";
16
15
  import type { StackSpec } from "./stack-spec.js";
17
16
 
@@ -37,12 +36,16 @@ describe("readStackSpec / writeStackSpec round-trip", () => {
37
36
  expect(read!.version).toBe(2);
38
37
  });
39
38
 
39
+ it("round-trips enabled addons", () => {
40
+ writeStackSpec(configDir, { version: 2, addons: ['chat', 'api'] });
41
+ expect(readStackSpec(configDir)).toEqual({ version: 2, addons: ['api', 'chat'] });
42
+ });
43
+
40
44
  it("writes to the canonical filename", () => {
41
45
  writeStackSpec(configDir, MINIMAL_SPEC);
42
46
  const expectedPath = join(configDir, STACK_SPEC_FILENAME);
43
- const read = readStackSpec(configDir);
44
- expect(read).not.toBeNull();
45
- expect(stackSpecPath(configDir)).toBe(expectedPath);
47
+ expect(expectedPath).toBe(join(configDir, "stack.yml"));
48
+ expect(readStackSpec(configDir)).not.toBeNull();
46
49
  });
47
50
 
48
51
  it("ignores legacy capabilities fields on read", () => {
@@ -79,16 +82,17 @@ describe("readStackSpec edge cases", () => {
79
82
  expect(spec).not.toBeNull();
80
83
  expect(spec!.version).toBe(2);
81
84
  });
82
- });
83
-
84
- // ── stackSpecPath / STACK_SPEC_FILENAME ──────────────────────────────────
85
85
 
86
- describe("stackSpecPath", () => {
87
- it("returns stackDir/stack.yml", () => {
88
- expect(stackSpecPath("/foo/config/stack")).toBe("/foo/config/stack/stack.yml");
86
+ it("ignores malformed addon names", () => {
87
+ writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\naddons:\n - chat\n - ../bad\n - API\n");
88
+ expect(readStackSpec(configDir)).toEqual({ version: 2, addons: ['chat'] });
89
89
  });
90
+ });
91
+
92
+ // ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
90
93
 
91
- it("uses STACK_SPEC_FILENAME constant", () => {
94
+ describe("STACK_SPEC_FILENAME", () => {
95
+ it("is stack.yml", () => {
92
96
  expect(STACK_SPEC_FILENAME).toBe("stack.yml");
93
97
  });
94
98
  });
@@ -14,8 +14,11 @@ import { stringify as yamlStringify, parse as yamlParse } from "yaml";
14
14
 
15
15
  export type StackSpec = {
16
16
  version: 2;
17
+ addons?: string[];
17
18
  };
18
19
 
20
+ const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
21
+
19
22
  // ── Constants ───────────────────────────────────────────────────────────
20
23
 
21
24
  export const STACK_SPEC_FILENAME = "stack.yml";
@@ -23,9 +26,7 @@ export const STACK_SPEC_FILENAME = "stack.yml";
23
26
  export const SPEC_DEFAULTS = {
24
27
  ports: {
25
28
  assistant: 3800,
26
- admin: 3880,
27
- adminOpencode: 3881,
28
- guardian: 3899,
29
+ hostUi: 3880,
29
30
  assistantSsh: 2222,
30
31
  },
31
32
  image: {
@@ -36,14 +37,10 @@ export const SPEC_DEFAULTS = {
36
37
 
37
38
  // ── Read / Write ────────────────────────────────────────────────────────
38
39
 
39
- export function stackSpecPath(configDir: string): string {
40
- return `${configDir}/${STACK_SPEC_FILENAME}`;
41
- }
42
-
43
40
  export function writeStackSpec(configDir: string, spec: StackSpec): void {
44
41
  mkdirSync(configDir, { recursive: true });
45
42
  const content = yamlStringify(spec, { indent: 2 });
46
- writeFileSync(stackSpecPath(configDir), content);
43
+ writeFileSync(`${configDir}/${STACK_SPEC_FILENAME}`, content);
47
44
  }
48
45
 
49
46
  /**
@@ -51,7 +48,7 @@ export function writeStackSpec(configDir: string, spec: StackSpec): void {
51
48
  * Only the version field is checked; legacy capability fields are ignored.
52
49
  */
53
50
  export function readStackSpec(configDir: string): StackSpec | null {
54
- const path = stackSpecPath(configDir);
51
+ const path = `${configDir}/${STACK_SPEC_FILENAME}`;
55
52
  if (!existsSync(path)) return null;
56
53
 
57
54
  let raw: unknown;
@@ -63,5 +60,29 @@ export function readStackSpec(configDir: string): StackSpec | null {
63
60
  if (typeof raw !== "object" || raw === null) return null;
64
61
  const obj = raw as Record<string, unknown>;
65
62
  if (obj.version !== 2) return null;
66
- return { version: 2 };
63
+ const spec: StackSpec = { version: 2 };
64
+ if (Array.isArray(obj.addons)) {
65
+ const addons = obj.addons
66
+ .filter((value): value is string => typeof value === 'string' && ADDON_NAME_RE.test(value))
67
+ .filter((value, index, all) => all.indexOf(value) === index)
68
+ .sort();
69
+ if (addons.length > 0) spec.addons = addons;
70
+ }
71
+ return spec;
72
+ }
73
+
74
+ export function listStackSpecAddons(configDir: string): string[] {
75
+ return readStackSpec(configDir)?.addons ?? [];
76
+ }
77
+
78
+ export function setStackSpecAddon(configDir: string, name: string, enabled: boolean): void {
79
+ if (!ADDON_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
80
+ const current = readStackSpec(configDir) ?? { version: 2 };
81
+ const addons = new Set(current.addons ?? []);
82
+ if (enabled) addons.add(name);
83
+ else addons.delete(name);
84
+ const next: StackSpec = { version: 2 };
85
+ const sorted = [...addons].sort();
86
+ if (sorted.length > 0) next.addons = sorted;
87
+ writeStackSpec(configDir, next);
67
88
  }
@@ -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
+ });