@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.
Files changed (66) 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 +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. 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
- "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,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 (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 (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", "stack.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);
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/ exists", () => {
58
- expect(existsSync(join(SKELETON_DIR, "config", "stack", "addons"))).toBe(true);
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.json"))).toBe(true);
78
+ expect(existsSync(join(SKELETON_DIR, "config", "assistant", "opencode.jsonc"))).toBe(true);
67
79
  });
68
- });
69
-
70
- // ── state/registry/ subdirectory ─────────────────────────────────────
71
80
 
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");
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("state/registry/automations/ exists", () => {
83
- expect(existsSync(join(SKELETON_DIR, "state", "registry", "automations"))).toBe(true);
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
- 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
- }
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
- // ── stash/ subdirectory ───────────────────────────────────────────────
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
- 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);
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("stash/vaults/ exists", () => {
105
- expect(existsSync(join(SKELETON_DIR, "stash", "vaults"))).toBe(true);
109
+ test("knowledge/secrets/ exists", () => {
110
+ expect(existsSync(join(SKELETON_DIR, "knowledge", "secrets"))).toBe(true);
106
111
  });
107
112
 
108
- test("stash/tasks/ exists", () => {
109
- expect(existsSync(join(SKELETON_DIR, "stash", "tasks"))).toBe(true);
113
+ test("knowledge/tasks/ exists", () => {
114
+ expect(existsSync(join(SKELETON_DIR, "knowledge", "tasks"))).toBe(true);
110
115
  });
111
116
  });
112
117
 
113
- // ── state/ service dirs ───────────────────────────────────────────────
118
+ // ── data/ service dirs ────────────────────────────────────────────────
114
119
 
115
- describe("skeleton: .openpalm/state/ service directories", () => {
116
- const serviceDirs = ["assistant", "admin", "guardian", "logs", "backups"];
120
+ describe("skeleton: .openpalm/data/ service directories", () => {
121
+ const serviceDirs = ["assistant", "guardian"];
117
122
 
118
123
  for (const dir of serviceDirs) {
119
- test(`state/${dir}/ exists`, () => {
120
- expect(existsSync(join(SKELETON_DIR, "state", dir))).toBe(true);
124
+ test(`data/${dir}/ exists`, () => {
125
+ expect(existsSync(join(SKELETON_DIR, "data", dir))).toBe(true);
121
126
  });
122
127
  }
123
128
 
124
- test("state/akm/data/ exists", () => {
125
- expect(existsSync(join(SKELETON_DIR, "state", "akm", "data"))).toBe(true);
129
+ test("data/akm/ exists", () => {
130
+ expect(existsSync(join(SKELETON_DIR, "data", "akm"))).toBe(true);
126
131
  });
127
132
 
128
- test("state/akm/state/ exists", () => {
129
- expect(existsSync(join(SKELETON_DIR, "state", "akm", "state"))).toBe(true);
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("state/logs/opencode/ exists", () => {
133
- expect(existsSync(join(SKELETON_DIR, "state", "logs", "opencode"))).toBe(true);
138
+ test("data/logs/ exists", () => {
139
+ expect(existsSync(join(SKELETON_DIR, "data", "logs"))).toBe(true);
134
140
  });
135
141
  });
136
142
 
137
- // ── cache/ and workspace/ ─────────────────────────────────────────────
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
- describe("skeleton: .openpalm/cache/ and workspace/", () => {
140
- test("cache/akm/ exists", () => {
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("cache/rollback/ exists", () => {
145
- expect(existsSync(join(SKELETON_DIR, "cache", "rollback"))).toBe(true);
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(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
- import { SPEC_DEFAULTS } from "./stack-spec.js";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
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 StackSpec.
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["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
  }
@@ -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/stash
30
+ stashDir: string; // homeDir/knowledge
31
31
  workspaceDir: string; // homeDir/workspace
32
- cacheDir: string; // homeDir/cache (regenerable/semi-persistent data)
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
-