@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.14

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 (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-user-env.test.ts +113 -0
  4. package/src/control-plane/akm-user-env.ts +144 -0
  5. package/src/control-plane/backup.ts +14 -5
  6. package/src/control-plane/channels.ts +48 -29
  7. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  8. package/src/control-plane/compose-args.test.ts +90 -31
  9. package/src/control-plane/compose-args.ts +119 -9
  10. package/src/control-plane/config-persistence.ts +87 -133
  11. package/src/control-plane/core-assets.test.ts +9 -9
  12. package/src/control-plane/core-assets.ts +24 -8
  13. package/src/control-plane/docker.ts +15 -14
  14. package/src/control-plane/env.test.ts +10 -10
  15. package/src/control-plane/env.ts +1 -1
  16. package/src/control-plane/extends-support.test.ts +8 -8
  17. package/src/control-plane/home.ts +34 -46
  18. package/src/control-plane/host-opencode.test.ts +82 -10
  19. package/src/control-plane/host-opencode.ts +42 -13
  20. package/src/control-plane/install-edge-cases.test.ts +94 -102
  21. package/src/control-plane/install-lock.ts +7 -7
  22. package/src/control-plane/lifecycle.ts +36 -34
  23. package/src/control-plane/markdown-task.ts +30 -50
  24. package/src/control-plane/paths.ts +62 -42
  25. package/src/control-plane/profile-ids.ts +21 -0
  26. package/src/control-plane/provider-models.ts +3 -3
  27. package/src/control-plane/registry.test.ts +97 -88
  28. package/src/control-plane/registry.ts +142 -109
  29. package/src/control-plane/rollback.ts +8 -38
  30. package/src/control-plane/scheduler.ts +7 -7
  31. package/src/control-plane/secret-audit.test.ts +159 -0
  32. package/src/control-plane/secret-audit.ts +255 -0
  33. package/src/control-plane/secret-mappings.ts +2 -2
  34. package/src/control-plane/secrets-files.test.ts +60 -0
  35. package/src/control-plane/secrets-files.ts +66 -0
  36. package/src/control-plane/secrets.ts +113 -86
  37. package/src/control-plane/setup-config.schema.json +1 -1
  38. package/src/control-plane/setup-status.ts +6 -11
  39. package/src/control-plane/setup.test.ts +42 -40
  40. package/src/control-plane/setup.ts +36 -31
  41. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  42. package/src/control-plane/spec-to-env.test.ts +22 -17
  43. package/src/control-plane/spec-to-env.ts +7 -2
  44. package/src/control-plane/stack-spec.test.ts +10 -0
  45. package/src/control-plane/stack-spec.ts +28 -1
  46. package/src/control-plane/types.ts +2 -4
  47. package/src/control-plane/ui-assets.ts +60 -58
  48. package/src/control-plane/validate.ts +13 -15
  49. package/src/index.ts +47 -15
  50. package/src/control-plane/akm-vault.test.ts +0 -105
  51. package/src/control-plane/akm-vault.ts +0 -311
  52. package/src/control-plane/migrate-0110.test.ts +0 -177
  53. package/src/control-plane/migrate-0110.ts +0 -99
  54. package/src/control-plane/registry-components.test.ts +0 -391
@@ -23,11 +23,11 @@ import {
23
23
  writeAuthJsonProviderKeys,
24
24
  } from "./secrets.js";
25
25
  import { createState } from "./lifecycle.js";
26
- import { writeStackSpec } from "./stack-spec.js";
26
+ import { readStackSpec, writeStackSpec } from "./stack-spec.js";
27
27
  import { writeVoiceVars } from "./spec-to-env.js";
28
28
  import type { ControlPlaneState } from "./types.js";
29
29
  import { validateSetupSpec } from "./setup-validation.js";
30
- import { getRegistryAutomation, setAddonEnabled } from "./registry.js";
30
+ import { getRegistryAutomation, setAddonEnabled, setAddonProfileSelection } from "./registry.js";
31
31
  export { validateSetupSpec } from "./setup-validation.js";
32
32
 
33
33
  const logger = createLogger("setup");
@@ -69,15 +69,15 @@ export type SetupSpec = {
69
69
  tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
70
70
  stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
71
71
  /**
72
- * Operator-supplied UI login password. Persisted to stack.env as
73
- * `OP_UI_LOGIN_PASSWORD`. Replaces the legacy `adminToken` field
74
- * (Phase 4 of docs/technical/auth-and-proxy-refactor-plan.md).
72
+ * Operator-supplied UI login password. Persisted as a file-based secret.
75
73
  */
76
74
  security: { uiLoginPassword: string };
77
75
  owner?: { name?: string; email?: string };
78
76
  connections: SetupConnection[];
79
77
  channelCredentials?: Record<string, Record<string, string>>;
80
78
  addons?: Record<string, boolean>;
79
+ voiceProfile?: string;
80
+ ollamaProfile?: string;
81
81
  imageTag?: string;
82
82
  hostAkm?: boolean;
83
83
  };
@@ -85,10 +85,8 @@ export type SetupSpec = {
85
85
  // ── Secrets Builder ──────────────────────────────────────────────────────
86
86
 
87
87
  /**
88
- * Build the stack.env update payload from a setup spec. Provider API
89
- * keys are NOT included here credentials live in OpenCode's auth.json
90
- * (see buildAuthJsonFromSetup), not stack.env. This function returns
91
- * only non-credential vars: owner identity and similar.
88
+ * Build the non-secret stack.env update payload from a setup spec.
89
+ * Provider API keys and channel credentials are written as file-based secrets.
92
90
  */
93
91
  export function buildSecretsFromSetup(
94
92
  connections: SetupConnection[],
@@ -124,7 +122,7 @@ export function buildAuthJsonFromSetup(
124
122
  }
125
123
 
126
124
  /**
127
- * Build the system-secret env update for the wizard / CLI install path.
125
+ * Build the system-secret update for the wizard / CLI install path.
128
126
  *
129
127
  * Phase 4 of the auth/proxy refactor collapsed the legacy
130
128
  * `OP_UI_TOKEN` / `OP_ASSISTANT_TOKEN` pair into a single operator login
@@ -132,8 +130,8 @@ export function buildAuthJsonFromSetup(
132
130
  * password; `requireAdmin()` compares the cookie against
133
131
  * `process.env.OP_UI_LOGIN_PASSWORD` via the existing `safeTokenCompare`.
134
132
  *
135
- * `OP_OPENCODE_PASSWORD` is generated by `ensureSystemSecrets()` on first
136
- * run and persists across reruns — it is not regenerated here.
133
+ * `OP_OPENCODE_PASSWORD` may be supplied explicitly as a file-based secret in
134
+ * `knowledge/secrets/op_opencode_password` when OpenCode auth is enabled.
137
135
  *
138
136
  * `existingSystemEnv` is unused now but the parameter is kept so callers
139
137
  * compile unchanged. It can be removed in a follow-up cleanup.
@@ -192,13 +190,13 @@ export async function performSetup(
192
190
  const validation = validateSetupSpec(input);
193
191
  if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
194
192
 
195
- const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
193
+ const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, voiceProfile, ollamaProfile, imageTag, hostAkm } = input;
196
194
  const state = opts?.state ?? createState();
197
195
 
198
196
  // Acquire install lock to prevent two concurrent setup runs from racing on
199
- // the same config directory. The lock lives in stateDir so it is co-located
197
+ // the same config directory. The lock lives in dataDir so it is co-located
200
198
  // with runtime state and the same path startDeploy uses.
201
- const lockHandle: InstallLockHandle | null = acquireInstallLock(state.stateDir);
199
+ const lockHandle: InstallLockHandle | null = acquireInstallLock(state.dataDir);
202
200
  if (lockHandle === null) {
203
201
  return {
204
202
  ok: false,
@@ -218,14 +216,15 @@ export async function performSetup(
218
216
  ensureHomeDirs();
219
217
  ensureSecrets(state);
220
218
  const existingSystemEnv = readStackEnv(state.stackDir);
221
- if (channelCredentials) Object.assign(updates, buildChannelCredentialEnvVars(channelCredentials));
219
+ const channelSecretUpdates = channelCredentials ? buildChannelCredentialEnvVars(channelCredentials) : {};
222
220
  // Pick up channel credential env vars not already provided in the spec
223
221
  for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
224
222
  for (const envKey of Object.values(mapping)) {
225
- if (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
223
+ if (!channelSecretUpdates[envKey] && process.env[envKey]) channelSecretUpdates[envKey] = process.env[envKey];
226
224
  }
227
225
  }
228
226
  updateSecretsEnv(state, updates);
227
+ updateSecretsEnv(state, channelSecretUpdates);
229
228
  patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
230
229
  // Provider API keys land in OpenCode's auth.json (bind-mounted into
231
230
  // the assistant container) — never in stack.env.
@@ -240,13 +239,13 @@ export async function performSetup(
240
239
  // single try/catch so that a disk-full or permission-denied mid-way returns a
241
240
  // clean error rather than leaving a broken half-installed ~/.openpalm/.
242
241
  try {
243
- // Write stack.yml (version marker only)
244
- writeStackSpec(state.stackDir, { version: 2 });
242
+ // Preserve addon enablement while refreshing the stack schema marker.
243
+ writeStackSpec(state.stackDir, readStackSpec(state.stackDir) ?? { version: 2 });
245
244
 
246
245
  // Write image tag and AKM mount paths to stack.env — atomic to avoid
247
246
  // partial writes if the process is interrupted mid-write.
248
- const systemEnvForAkm = existsSync(`${state.stackDir}/stack.env`)
249
- ? readFileSync(`${state.stackDir}/stack.env`, "utf-8")
247
+ const systemEnvForAkm = existsSync(`${state.stashDir}/env/stack.env`)
248
+ ? readFileSync(`${state.stashDir}/env/stack.env`, "utf-8")
250
249
  : "";
251
250
  const akmUpdates: Record<string, string> = {};
252
251
  if (imageTag) akmUpdates.OP_IMAGE_TAG = imageTag;
@@ -254,14 +253,11 @@ export async function performSetup(
254
253
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
255
254
  if (home) {
256
255
  akmUpdates.OP_AKM_STASH = `${home}/akm`;
257
- akmUpdates.OP_AKM_DATA = `${home}/.local/share/akm`;
258
- akmUpdates.OP_AKM_STATE = `${home}/.local/state/akm`;
259
- akmUpdates.OP_AKM_CACHE = `${home}/.cache/akm`;
260
256
  akmUpdates.OP_AKM_CONFIG = `${home}/.config/akm`;
261
257
  }
262
258
  }
263
259
  if (Object.keys(akmUpdates).length > 0) {
264
- writeFileAtomic(`${state.stackDir}/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
260
+ writeFileAtomic(`${state.stashDir}/env/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
265
261
  }
266
262
 
267
263
  // Write akm config with LLM and embedding settings from setup — atomic.
@@ -302,24 +298,33 @@ export async function performSetup(
302
298
  }
303
299
 
304
300
  // Enable requested addons (channels like discord, slack, etc.)
305
- // setAddonEnabled copies the compose overlay AND generates CHANNEL_<NAME>_SECRET in guardian.env
301
+ // setAddonEnabled records explicit activation state and ensures channel secret files.
306
302
  if (addons) {
307
303
  for (const [name, enabled] of Object.entries(addons)) {
308
- if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true);
304
+ if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true, state);
309
305
  }
310
306
  }
311
307
 
308
+
309
+ if (voiceProfile?.trim()) {
310
+ setAddonProfileSelection(state.stackDir, 'voice', voiceProfile.trim(), state);
311
+ }
312
+
313
+ if (ollamaProfile?.trim()) {
314
+ setAddonProfileSelection(state.stackDir, 'ollama', ollamaProfile.trim(), state);
315
+ }
316
+
312
317
  ensureOpenCodeConfig();
313
318
 
314
319
  // Seed default automation into the AKM stash. Idempotent — existing files
315
320
  // are left alone so user edits survive re-install and upgrade.
316
321
  const tasksDir = join(state.stashDir, "tasks");
317
322
  mkdirSync(tasksDir, { recursive: true });
318
- const akmImproveDest = join(tasksDir, "akm-improve.md");
323
+ const akmImproveDest = join(tasksDir, "akm-improve.yml");
319
324
  if (!existsSync(akmImproveDest)) {
320
- const akmImproveMd = getRegistryAutomation("akm-improve");
321
- if (akmImproveMd) {
322
- writeFileSync(akmImproveDest, akmImproveMd);
325
+ const akmImproveTask = getRegistryAutomation("akm-improve");
326
+ if (akmImproveTask) {
327
+ writeFileSync(akmImproveDest, akmImproveTask);
323
328
  logger.info("seeded default automation", { name: "akm-improve" });
324
329
  } else {
325
330
  logger.warn("default automation missing from registry; skipping seed", {
@@ -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", "admin", "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", () => {
@@ -62,54 +62,59 @@ describe("deriveSystemEnvFromSpec", () => {
62
62
  });
63
63
 
64
64
  describe("writeVoiceVars", () => {
65
+ // writeVoiceVars takes a stackDir (<home>/config/stack) and writes the env
66
+ // to <home>/knowledge/env/stack.env. Build that layout per test.
67
+ let stackDir = "";
68
+ let stackEnv = "";
69
+ beforeEach(() => {
70
+ stackDir = join(tempDir, "config", "stack");
71
+ stackEnv = join(tempDir, "knowledge", "env", "stack.env");
72
+ mkdirSync(stackDir, { recursive: true });
73
+ mkdirSync(join(tempDir, "knowledge", "env"), { recursive: true });
74
+ });
75
+
65
76
  test("writes TTS vars to stack.env", () => {
66
- mkdirSync(tempDir, { recursive: true });
67
- writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
77
+ writeFileSync(stackEnv, "# stack env\n");
68
78
 
69
79
  writeVoiceVars({
70
80
  tts: { baseURL: "https://tts.example.com/v1", model: "tts-1", voice: "alloy" },
71
- }, tempDir);
81
+ }, stackDir);
72
82
 
73
- const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
83
+ const content = readFileSync(stackEnv, "utf-8");
74
84
  expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
75
85
  expect(content).toContain("OP_TTS_MODEL=tts-1");
76
86
  expect(content).toContain("OP_TTS_VOICE=alloy");
77
87
  });
78
88
 
79
89
  test("writes STT vars to stack.env", () => {
80
- mkdirSync(tempDir, { recursive: true });
81
- writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
90
+ writeFileSync(stackEnv, "# stack env\n");
82
91
 
83
92
  writeVoiceVars({
84
93
  stt: { baseURL: "https://stt.example.com/v1", model: "whisper-1", language: "en" },
85
- }, tempDir);
94
+ }, stackDir);
86
95
 
87
- const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
96
+ const content = readFileSync(stackEnv, "utf-8");
88
97
  expect(content).toContain("OP_STT_BASE_URL=https://stt.example.com/v1");
89
98
  expect(content).toContain("OP_STT_MODEL=whisper-1");
90
99
  expect(content).toContain("OP_STT_LANGUAGE=en");
91
100
  });
92
101
 
93
102
  test("creates stack.env if it does not exist", () => {
94
- mkdirSync(tempDir, { recursive: true });
95
-
96
103
  writeVoiceVars({
97
104
  tts: { baseURL: "https://tts.example.com/v1", model: "tts-1" },
98
- }, tempDir);
105
+ }, stackDir);
99
106
 
100
- const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
107
+ const content = readFileSync(stackEnv, "utf-8");
101
108
  expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
102
109
  });
103
110
 
104
111
  test("is a no-op when no vars are provided", () => {
105
- mkdirSync(tempDir, { recursive: true });
106
- const stackEnvPath = join(tempDir, "stack.env");
107
- writeFileSync(stackEnvPath, "EXISTING=value\n");
112
+ writeFileSync(stackEnv, "EXISTING=value\n");
108
113
 
109
- writeVoiceVars({}, tempDir);
114
+ writeVoiceVars({}, stackDir);
110
115
 
111
116
  // File should be unchanged
112
- const content = readFileSync(stackEnvPath, "utf-8");
117
+ const content = readFileSync(stackEnv, "utf-8");
113
118
  expect(content).toBe("EXISTING=value\n");
114
119
  });
115
120
  });
@@ -6,9 +6,12 @@
6
6
  */
7
7
 
8
8
  import { SPEC_DEFAULTS } from "./stack-spec.js";
9
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { dirname } from "node:path";
10
11
  import { mergeEnvContent } from "./env.js";
11
12
  import { resolveOperatorIds } from "./operator-ids.js";
13
+ import { assertNoSecretLikeStackEnvKeys } from './secrets.js';
14
+ import { stackEnvPathFromStackDir } from './paths.js';
12
15
 
13
16
  /**
14
17
  * Derive the system.env key-value pairs from the StackSpec.
@@ -75,7 +78,7 @@ export type VoiceVarsConfig = {
75
78
  * engine without filling in URL/model still persists.
76
79
  */
77
80
  export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
78
- const stackEnvPath = `${stackDir}/stack.env`;
81
+ const stackEnvPath = stackEnvPathFromStackDir(stackDir);
79
82
  const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
80
83
  const vars: Record<string, string> = {};
81
84
 
@@ -101,10 +104,12 @@ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void
101
104
  }
102
105
 
103
106
  if (Object.keys(vars).length === 0) return;
107
+ assertNoSecretLikeStackEnvKeys(vars);
104
108
 
105
109
  let content = mergeEnvContent(base, vars, {
106
110
  sectionHeader: "# ── Voice Channel (TTS/STT) ──────────────────────────────────────────",
107
111
  });
108
112
  if (!content.endsWith("\n")) content += "\n";
113
+ mkdirSync(dirname(stackEnvPath), { recursive: true, mode: 0o700 });
109
114
  writeFileSync(stackEnvPath, content, { mode: 0o600 });
110
115
  }
@@ -36,6 +36,11 @@ describe("readStackSpec / writeStackSpec round-trip", () => {
36
36
  expect(read!.version).toBe(2);
37
37
  });
38
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
+
39
44
  it("writes to the canonical filename", () => {
40
45
  writeStackSpec(configDir, MINIMAL_SPEC);
41
46
  const expectedPath = join(configDir, STACK_SPEC_FILENAME);
@@ -77,6 +82,11 @@ describe("readStackSpec edge cases", () => {
77
82
  expect(spec).not.toBeNull();
78
83
  expect(spec!.version).toBe(2);
79
84
  });
85
+
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
+ });
80
90
  });
81
91
 
82
92
  // ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
@@ -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";
@@ -59,5 +62,29 @@ export function readStackSpec(configDir: string): StackSpec | null {
59
62
  if (typeof raw !== "object" || raw === null) return null;
60
63
  const obj = raw as Record<string, unknown>;
61
64
  if (obj.version !== 2) return null;
62
- return { version: 2 };
65
+ const spec: StackSpec = { version: 2 };
66
+ if (Array.isArray(obj.addons)) {
67
+ const addons = obj.addons
68
+ .filter((value): value is string => typeof value === 'string' && ADDON_NAME_RE.test(value))
69
+ .filter((value, index, all) => all.indexOf(value) === index)
70
+ .sort();
71
+ if (addons.length > 0) spec.addons = addons;
72
+ }
73
+ return spec;
74
+ }
75
+
76
+ export function listStackSpecAddons(configDir: string): string[] {
77
+ return readStackSpec(configDir)?.addons ?? [];
78
+ }
79
+
80
+ export function setStackSpecAddon(configDir: string, name: string, enabled: boolean): void {
81
+ if (!ADDON_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
82
+ const current = readStackSpec(configDir) ?? { version: 2 };
83
+ const addons = new Set(current.addons ?? []);
84
+ if (enabled) addons.add(name);
85
+ else addons.delete(name);
86
+ const next: StackSpec = { version: 2 };
87
+ const sorted = [...addons].sort();
88
+ if (sorted.length > 0) next.addons = sorted;
89
+ writeStackSpec(configDir, next);
63
90
  }
@@ -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
-