@openpalm/lib 0.10.2 → 0.11.0-beta.10

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 +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -1,21 +1,8 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { deriveSystemEnvFromSpec, writeCapabilityVars } from "./spec-to-env.js";
6
- import type { StackSpec } from "./stack-spec.js";
7
-
8
- function makeSpec(overrides?: Partial<StackSpec>): StackSpec {
9
- return {
10
- version: 2,
11
- capabilities: {
12
- llm: "openai/gpt-4o",
13
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
14
- memory: { userId: "default_user" },
15
- },
16
- ...overrides,
17
- };
18
- }
5
+ import { deriveSystemEnvFromSpec, writeVoiceVars } from "./spec-to-env.js";
19
6
 
20
7
  let tempDir = "";
21
8
 
@@ -29,72 +16,100 @@ afterEach(() => {
29
16
 
30
17
  describe("deriveSystemEnvFromSpec", () => {
31
18
  test("produces OP_HOME", () => {
32
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
19
+ const result = deriveSystemEnvFromSpec("/home/op");
33
20
  expect(result.OP_HOME).toBe("/home/op");
34
21
  });
35
22
 
36
23
  test("produces default port values", () => {
37
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
24
+ const result = deriveSystemEnvFromSpec("/home/op");
38
25
  expect(result.OP_ASSISTANT_PORT).toBe("3800");
39
- expect(result.OP_MEMORY_PORT).toBe("3898");
40
- expect(result.OP_GUARDIAN_PORT).toBe("3899");
41
26
  });
42
27
 
43
- test("does not include LLM provider in system env (lives in OP_CAP_* vars in stack.env)", () => {
44
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
45
- expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
46
- expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
28
+ test("does not emit OP_GUARDIAN_PORT (guardian is network-only, no host mapping)", () => {
29
+ const result = deriveSystemEnvFromSpec("/home/op");
30
+ expect(result.OP_GUARDIAN_PORT).toBeUndefined();
47
31
  });
48
32
 
49
- test("does not include embedding config in system env (lives in OP_CAP_* vars in stack.env)", () => {
50
- const result = deriveSystemEnvFromSpec(makeSpec(), "/home/op");
51
- expect(result.EMBEDDING_MODEL).toBeUndefined();
52
- expect(result.EMBEDDING_DIMS).toBeUndefined();
33
+ test("does not include the retired memory service port", () => {
34
+ const result = deriveSystemEnvFromSpec("/home/op");
35
+ const retired = "OP_" + "MEMORY_PORT";
36
+ expect(result[retired]).toBeUndefined();
37
+ });
38
+
39
+ test("does not include LLM provider in system env", () => {
40
+ const result = deriveSystemEnvFromSpec("/home/op");
41
+ expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
42
+ expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
53
43
  });
54
44
 
55
45
  test("does not include removed feature flags", () => {
56
- const spec = makeSpec();
57
- const result = deriveSystemEnvFromSpec(spec, "/home/op");
46
+ const result = deriveSystemEnvFromSpec("/home/op");
58
47
  expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
59
48
  expect(result.OP_ADMIN_ENABLED).toBeUndefined();
60
49
  });
50
+
51
+ test("auto-detects OP_UID/OP_GID from the homeDir owner (not hard-coded 1000)", () => {
52
+ // tempDir is owned by the test process, which on a CI runner or
53
+ // dev box is typically NOT root and NOT necessarily UID 1000. The
54
+ // assertion that matters: we read the value off statSync, not a
55
+ // hard-coded constant.
56
+ if (process.platform === "win32") return;
57
+ const expected = statSync(tempDir);
58
+ const result = deriveSystemEnvFromSpec(tempDir);
59
+ expect(result.OP_UID).toBe(String(expected.uid));
60
+ expect(result.OP_GID).toBe(String(expected.gid));
61
+ });
61
62
  });
62
63
 
63
- describe("writeCapabilityVars", () => {
64
- test("writes OP_CAP_* vars to stack.env", () => {
65
- const spec = makeSpec({
66
- capabilities: {
67
- llm: "openai/gpt-4o",
68
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
69
- memory: { userId: "default_user" },
70
- },
71
- });
72
-
73
- // Seed stack.env so writeCapabilityVars can read/merge it
74
- const vaultDir = join(tempDir, "vault");
75
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
76
- writeFileSync(join(vaultDir, "stack", "stack.env"), "# stack env\n");
77
-
78
- writeCapabilityVars(spec, vaultDir);
79
-
80
- const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
81
- expect(stackEnvContent).toContain("OP_CAP_LLM_PROVIDER=openai");
82
- expect(stackEnvContent).toContain("OP_CAP_LLM_MODEL=gpt-4o");
83
- expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_MODEL=text-embedding-3-small");
84
- expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_DIMS=1536");
85
- expect(stackEnvContent).toContain("MEMORY_USER_ID=default_user");
64
+ describe("writeVoiceVars", () => {
65
+ test("writes TTS vars to stack.env", () => {
66
+ mkdirSync(tempDir, { recursive: true });
67
+ writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
68
+
69
+ writeVoiceVars({
70
+ tts: { baseURL: "https://tts.example.com/v1", model: "tts-1", voice: "alloy" },
71
+ }, tempDir);
72
+
73
+ const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
74
+ expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
75
+ expect(content).toContain("OP_TTS_MODEL=tts-1");
76
+ expect(content).toContain("OP_TTS_VOICE=alloy");
77
+ });
78
+
79
+ test("writes STT vars to stack.env", () => {
80
+ mkdirSync(tempDir, { recursive: true });
81
+ writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
82
+
83
+ writeVoiceVars({
84
+ stt: { baseURL: "https://stt.example.com/v1", model: "whisper-1", language: "en" },
85
+ }, tempDir);
86
+
87
+ const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
88
+ expect(content).toContain("OP_STT_BASE_URL=https://stt.example.com/v1");
89
+ expect(content).toContain("OP_STT_MODEL=whisper-1");
90
+ expect(content).toContain("OP_STT_LANGUAGE=en");
86
91
  });
87
92
 
88
- test("does not create managed.env files", () => {
89
- const spec = makeSpec();
93
+ test("creates stack.env if it does not exist", () => {
94
+ mkdirSync(tempDir, { recursive: true });
95
+
96
+ writeVoiceVars({
97
+ tts: { baseURL: "https://tts.example.com/v1", model: "tts-1" },
98
+ }, tempDir);
99
+
100
+ const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
101
+ expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
102
+ });
90
103
 
91
- const vaultDir = join(tempDir, "vault");
92
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
93
- writeFileSync(join(vaultDir, "stack", "stack.env"), "# stack env\n");
104
+ 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");
94
108
 
95
- writeCapabilityVars(spec, vaultDir);
109
+ writeVoiceVars({}, tempDir);
96
110
 
97
- const managedEnvPath = join(vaultDir, "stack", "services", "memory", "managed.env");
98
- expect(() => readFileSync(managedEnvPath)).toThrow();
111
+ // File should be unchanged
112
+ const content = readFileSync(stackEnvPath, "utf-8");
113
+ expect(content).toBe("EXISTING=value\n");
99
114
  });
100
115
  });
@@ -1,30 +1,20 @@
1
1
  /**
2
2
  * Config-to-env derivation pipeline.
3
3
  *
4
- * Reads a StackSpec v2 and deterministically produces:
5
- * 1. System env vars for stack.env (non-secret infrastructure config)
6
- * 2. Resolved capability vars (OP_CAP_*) written to stack.env
4
+ * Produces system env vars for stack.env (non-secret infrastructure config).
5
+ * Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
7
6
  */
8
7
 
9
- import type { StackSpec } from "./stack-spec.js";
10
- import { SPEC_DEFAULTS, parseCapabilityString } from "./stack-spec.js";
8
+ import { SPEC_DEFAULTS } from "./stack-spec.js";
11
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
- import { dirname } from "node:path";
13
- import { mergeEnvContent, parseEnvContent } from "./env.js";
14
- import { PROVIDER_DEFAULT_URLS, PROVIDER_KEY_MAP, OLLAMA_INSTACK_URL } from "../provider-constants.js";
15
- import { listEnabledAddonIds } from "./registry.js";
10
+ import { mergeEnvContent } from "./env.js";
11
+ import { resolveOperatorIds } from "./operator-ids.js";
16
12
 
17
13
  /**
18
14
  * Derive the system.env key-value pairs from the StackSpec.
19
15
  * Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
20
16
  */
21
- export function deriveSystemEnvFromSpec(
22
- spec: StackSpec,
23
- homeDir: string,
24
- ): Record<string, string> {
25
- const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
26
- const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
27
-
17
+ export function deriveSystemEnvFromSpec(homeDir: string): Record<string, string> {
28
18
  const ports = SPEC_DEFAULTS.ports;
29
19
  const image = SPEC_DEFAULTS.image;
30
20
 
@@ -32,163 +22,88 @@ export function deriveSystemEnvFromSpec(
32
22
 
33
23
  // Paths
34
24
  result["OP_HOME"] = homeDir;
35
- result["OP_UID"] = String(uid);
36
- result["OP_GID"] = String(gid);
37
- result["OP_DOCKER_SOCK"] = process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock";
38
25
 
26
+ // Operator UID/GID — auto-detect from OP_HOME owner (or process UID
27
+ // as fallback). Skipped on Windows where containers run in WSL2 and
28
+ // OP_UID has no meaning on the host process.
29
+ const ids = resolveOperatorIds(homeDir);
30
+ if (ids) {
31
+ result["OP_UID"] = String(ids.uid);
32
+ result["OP_GID"] = String(ids.gid);
33
+ }
39
34
  // Image
40
35
  result["OP_IMAGE_NAMESPACE"] = image.namespace;
41
36
  result["OP_IMAGE_TAG"] = image.tag;
42
37
 
43
- // Ports
38
+ // Ports — only the services that publish to the host. Guardian is
39
+ // network-only (no host port mapping) so OP_GUARDIAN_PORT is no longer
40
+ // emitted; channels reach it via Docker DNS at http://guardian:8080.
44
41
  result["OP_ASSISTANT_PORT"] = String(ports.assistant);
45
42
  result["OP_ADMIN_PORT"] = String(ports.admin);
46
43
  result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
47
- result["OP_MEMORY_PORT"] = String(ports.memory);
48
- result["OP_GUARDIAN_PORT"] = String(ports.guardian);
49
44
  result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
50
45
 
51
46
  return result;
52
47
  }
53
48
 
54
- // ── Capability Resolution ────────────────────────────────────────────────
55
-
56
- /**
57
- * Resolve all capabilities from stack.yml and write OP_CAP_* vars into stack.env.
58
- *
59
- * Reads raw API keys from the current stack.env, resolves provider → base URL → API key
60
- * for each capability, and merges the OP_CAP_* section into stack.env.
61
- *
62
- * Services consume these via compose ${VAR} substitution in their environment blocks.
63
- */
64
- export function writeCapabilityVars(spec: StackSpec, vaultDir: string): void {
65
- const stackEnvPath = `${vaultDir}/stack/stack.env`;
66
- const stackEnv = existsSync(stackEnvPath)
67
- ? parseEnvContent(readFileSync(stackEnvPath, "utf-8"))
68
- : {};
69
-
70
- const resolveKey = (provider: string): string => {
71
- const keyVar = PROVIDER_KEY_MAP[provider];
72
- return keyVar ? (stackEnv[keyVar] || "") : "";
73
- };
74
-
75
- /** Providers that do NOT use an OpenAI-compatible /v1 path prefix. */
76
- const NO_V1_SUFFIX = new Set(["ollama", "google"]);
77
-
78
- const ensureV1 = (url: string, provider: string): string => {
79
- if (!url || NO_V1_SUFFIX.has(provider)) return url;
80
- return url.endsWith("/v1") ? url : `${url.replace(/\/+$/, "")}/v1`;
81
- };
82
-
83
- /** Map provider → env var for user-configured base URL overrides. */
84
- const BASE_URL_ENV_MAP: Record<string, string> = {
85
- openai: "OPENAI_BASE_URL",
86
- anthropic: "ANTHROPIC_BASE_URL",
87
- groq: "GROQ_BASE_URL",
88
- mistral: "MISTRAL_BASE_URL",
89
- together: "TOGETHER_BASE_URL",
90
- deepseek: "DEEPSEEK_BASE_URL",
91
- xai: "XAI_BASE_URL",
92
- google: "GOOGLE_BASE_URL",
93
- huggingface: "HF_BASE_URL",
94
- ollama: "OLLAMA_BASE_URL",
95
- lmstudio: "LMSTUDIO_BASE_URL",
96
- "model-runner": "MODEL_RUNNER_BASE_URL",
97
- "openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",
98
- };
99
-
100
- const resolveUrl = (provider: string): string => {
101
- if (provider === "ollama" && listEnabledAddonIds(dirname(vaultDir)).includes("ollama")) return OLLAMA_INSTACK_URL;
102
- // Check stack.env for a user-configured base URL override for any provider
103
- const urlEnvKey = BASE_URL_ENV_MAP[provider];
104
- if (urlEnvKey && stackEnv[urlEnvKey]) {
105
- return ensureV1(stackEnv[urlEnvKey], provider);
106
- }
107
- const defaultUrl = PROVIDER_DEFAULT_URLS[provider] || "";
108
- return ensureV1(defaultUrl, provider);
49
+ // ── Voice Channel Env Vars ────────────────────────────────────────────────
50
+
51
+ export type VoiceVarsConfig = {
52
+ tts?: {
53
+ enabled?: boolean;
54
+ /** Engine name (e.g. "kokoro", "elevenlabs", "browser"). */
55
+ engine?: string;
56
+ /** Optional sub-provider qualifier when an engine fronts multiple providers. */
57
+ provider?: string;
58
+ baseURL?: string;
59
+ model?: string;
60
+ voice?: string;
109
61
  };
110
-
111
- const caps: Record<string, string> = {};
112
-
113
- /** Set a list of capability env vars to empty string (disabled capability). */
114
- const clearCapVars = (prefix: string, fields: string[]): void => {
115
- for (const f of fields) caps[`${prefix}_${f}`] = "";
62
+ stt?: {
63
+ enabled?: boolean;
64
+ engine?: string;
65
+ provider?: string;
66
+ baseURL?: string;
67
+ model?: string;
68
+ language?: string;
116
69
  };
70
+ };
117
71
 
118
- // ── LLM ──
119
- const { provider: llmP, model: llmM } = parseCapabilityString(spec.capabilities.llm);
120
- caps.OP_CAP_LLM_PROVIDER = llmP;
121
- caps.OP_CAP_LLM_MODEL = llmM;
122
- caps.OP_CAP_LLM_BASE_URL = resolveUrl(llmP);
123
- caps.OP_CAP_LLM_API_KEY = resolveKey(llmP);
124
-
125
- // ── SLM ──
126
- if (spec.capabilities.slm) {
127
- const { provider: slmP, model: slmM } = parseCapabilityString(spec.capabilities.slm);
128
- caps.OP_CAP_SLM_PROVIDER = slmP;
129
- caps.OP_CAP_SLM_MODEL = slmM;
130
- caps.OP_CAP_SLM_BASE_URL = resolveUrl(slmP);
131
- caps.OP_CAP_SLM_API_KEY = resolveKey(slmP);
132
- } else {
133
- clearCapVars("OP_CAP_SLM", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY"]);
134
- }
135
-
136
- // ── Embeddings ──
137
- const emb = spec.capabilities.embeddings;
138
- caps.OP_CAP_EMBEDDINGS_PROVIDER = emb.provider;
139
- caps.OP_CAP_EMBEDDINGS_MODEL = emb.model;
140
- caps.OP_CAP_EMBEDDINGS_BASE_URL = resolveUrl(emb.provider);
141
- caps.OP_CAP_EMBEDDINGS_API_KEY = resolveKey(emb.provider);
142
- caps.OP_CAP_EMBEDDINGS_DIMS = String(emb.dims);
143
-
144
- // ── TTS ──
145
- const tts = spec.capabilities.tts;
146
- if (tts?.enabled) {
147
- const p = tts.provider || llmP;
148
- caps.OP_CAP_TTS_PROVIDER = p;
149
- caps.OP_CAP_TTS_MODEL = tts.model || "";
150
- caps.OP_CAP_TTS_BASE_URL = resolveUrl(p);
151
- caps.OP_CAP_TTS_API_KEY = resolveKey(p);
152
- caps.OP_CAP_TTS_VOICE = tts.voice || "";
153
- caps.OP_CAP_TTS_FORMAT = tts.format || "";
154
- } else {
155
- clearCapVars("OP_CAP_TTS", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "VOICE", "FORMAT"]);
156
- }
157
-
158
- // ── STT ──
159
- const stt = spec.capabilities.stt;
160
- if (stt?.enabled) {
161
- const p = stt.provider || llmP;
162
- caps.OP_CAP_STT_PROVIDER = p;
163
- caps.OP_CAP_STT_MODEL = stt.model || "";
164
- caps.OP_CAP_STT_BASE_URL = resolveUrl(p);
165
- caps.OP_CAP_STT_API_KEY = resolveKey(p);
166
- caps.OP_CAP_STT_LANGUAGE = stt.language || "";
167
- } else {
168
- clearCapVars("OP_CAP_STT", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "LANGUAGE"]);
72
+ /**
73
+ * Write TTS/STT env vars to stack.env for the voice channel container.
74
+ * `engine` always writes (even if it's the only field) so picking an
75
+ * engine without filling in URL/model still persists.
76
+ */
77
+ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
78
+ const stackEnvPath = `${stackDir}/stack.env`;
79
+ const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
80
+ const vars: Record<string, string> = {};
81
+
82
+ // OP_ prefix is mandatory: unprefixed TTS_*/STT_* names collide with
83
+ // other tooling (OpenAI clients, kokoro-fastapi, etc.) commonly set in
84
+ // operator shells. The UI server only reads OP_-prefixed vars from
85
+ // process.env, so a leaked host TTS_VOICE can't silently override the
86
+ // saved selection.
87
+ const { tts, stt } = config;
88
+ if (tts?.enabled !== false) {
89
+ if (tts?.engine) vars["OP_TTS_ENGINE"] = tts.engine;
90
+ if (tts?.provider) vars["OP_TTS_PROVIDER"] = tts.provider;
91
+ if (tts?.baseURL) vars["OP_TTS_BASE_URL"] = tts.baseURL;
92
+ if (tts?.model) vars["OP_TTS_MODEL"] = tts.model;
93
+ if (tts?.voice) vars["OP_TTS_VOICE"] = tts.voice;
169
94
  }
170
-
171
- // ── Reranking ──
172
- const rr = spec.capabilities.reranking;
173
- if (rr?.enabled) {
174
- const p = rr.provider || llmP;
175
- caps.OP_CAP_RERANKING_PROVIDER = p;
176
- caps.OP_CAP_RERANKING_MODEL = rr.model || "";
177
- caps.OP_CAP_RERANKING_BASE_URL = resolveUrl(p);
178
- caps.OP_CAP_RERANKING_API_KEY = resolveKey(p);
179
- caps.OP_CAP_RERANKING_TOP_K = rr.topK ? String(rr.topK) : "";
180
- caps.OP_CAP_RERANKING_TOP_N = rr.topN ? String(rr.topN) : "";
181
- } else {
182
- clearCapVars("OP_CAP_RERANKING", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "TOP_K", "TOP_N"]);
95
+ if (stt?.enabled !== false) {
96
+ if (stt?.engine) vars["OP_STT_ENGINE"] = stt.engine;
97
+ if (stt?.provider) vars["OP_STT_PROVIDER"] = stt.provider;
98
+ if (stt?.baseURL) vars["OP_STT_BASE_URL"] = stt.baseURL;
99
+ if (stt?.model) vars["OP_STT_MODEL"] = stt.model;
100
+ if (stt?.language) vars["OP_STT_LANGUAGE"] = stt.language;
183
101
  }
184
102
 
185
- // ── Memory ──
186
- caps.MEMORY_USER_ID = spec.capabilities.memory.userId || "default_user";
103
+ if (Object.keys(vars).length === 0) return;
187
104
 
188
- // Merge into stack.env
189
- const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
190
- let content = mergeEnvContent(base, caps, {
191
- sectionHeader: "# ── Resolved Capabilities (from stack.yml) ─────────────────────────",
105
+ let content = mergeEnvContent(base, vars, {
106
+ sectionHeader: "# ── Voice Channel (TTS/STT) ──────────────────────────────────────────",
192
107
  });
193
108
  if (!content.endsWith("\n")) content += "\n";
194
109
  writeFileSync(stackEnvPath, content, { mode: 0o600 });
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Stack spec parser tests.
3
3
  *
4
- * Verifies that readStackSpec / writeStackSpec produce consistent results
5
- * and that all addon resolution goes through the canonical lib functions.
4
+ * Verifies that readStackSpec / writeStackSpec produce consistent results.
6
5
  */
7
6
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
8
7
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -12,10 +11,6 @@ import {
12
11
  readStackSpec,
13
12
  writeStackSpec,
14
13
  STACK_SPEC_FILENAME,
15
- stackSpecPath,
16
- parseCapabilityString,
17
- formatCapabilityString,
18
- updateCapability,
19
14
  } from "./stack-spec.js";
20
15
  import type { StackSpec } from "./stack-spec.js";
21
16
 
@@ -29,37 +24,33 @@ afterEach(() => {
29
24
  rmSync(configDir, { recursive: true, force: true });
30
25
  });
31
26
 
32
- // ── Helpers ─────────────────────────────────────────────────────────────
33
-
34
- function makeSpec(): StackSpec {
35
- return {
36
- version: 2,
37
- capabilities: {
38
- llm: "openai/gpt-4o",
39
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
40
- memory: { userId: "test-user" },
41
- },
42
- };
43
- }
27
+ const MINIMAL_SPEC: StackSpec = { version: 2 };
44
28
 
45
29
  // ── readStackSpec / writeStackSpec round-trip ────────────────────────────
46
30
 
47
31
  describe("readStackSpec / writeStackSpec round-trip", () => {
48
- it("round-trips a spec with capabilities only", () => {
49
- const spec = makeSpec();
50
- writeStackSpec(configDir, spec);
32
+ it("round-trips a minimal spec", () => {
33
+ writeStackSpec(configDir, MINIMAL_SPEC);
51
34
  const read = readStackSpec(configDir);
52
35
  expect(read).not.toBeNull();
53
36
  expect(read!.version).toBe(2);
54
- expect(read!.capabilities.llm).toBe("openai/gpt-4o");
55
37
  });
56
38
 
57
39
  it("writes to the canonical filename", () => {
58
- writeStackSpec(configDir, makeSpec());
40
+ writeStackSpec(configDir, MINIMAL_SPEC);
59
41
  const expectedPath = join(configDir, STACK_SPEC_FILENAME);
42
+ expect(expectedPath).toBe(join(configDir, "stack.yml"));
43
+ expect(readStackSpec(configDir)).not.toBeNull();
44
+ });
45
+
46
+ it("ignores legacy capabilities fields on read", () => {
47
+ // On upgraded installs, old stack.yml may have capabilities — should still parse
48
+ writeFileSync(join(configDir, STACK_SPEC_FILENAME),
49
+ "version: 2\ncapabilities:\n llm: openai/gpt-4o\n embeddings:\n provider: openai\n model: text-embedding-3-small\n dims: 1536\n"
50
+ );
60
51
  const read = readStackSpec(configDir);
61
52
  expect(read).not.toBeNull();
62
- expect(stackSpecPath(configDir)).toBe(expectedPath);
53
+ expect(read!.version).toBe(2);
63
54
  });
64
55
  });
65
56
 
@@ -80,71 +71,18 @@ describe("readStackSpec edge cases", () => {
80
71
  expect(readStackSpec(configDir)).toBeNull();
81
72
  });
82
73
 
83
- it("returns null when capabilities is missing", () => {
74
+ it("returns valid spec for version 2 with no other fields", () => {
84
75
  writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\n");
85
- expect(readStackSpec(configDir)).toBeNull();
86
- });
87
- });
88
-
89
- // ── Capability helpers ──────────────────────────────────────────────────
90
-
91
- describe("parseCapabilityString", () => {
92
- it("splits provider/model", () => {
93
- expect(parseCapabilityString("openai/gpt-4o")).toEqual({ provider: "openai", model: "gpt-4o" });
94
- });
95
-
96
- it("handles model with slashes", () => {
97
- expect(parseCapabilityString("ollama/qwen/2.5-coder:3b")).toEqual({ provider: "ollama", model: "qwen/2.5-coder:3b" });
98
- });
99
-
100
- it("handles missing slash", () => {
101
- expect(parseCapabilityString("openai")).toEqual({ provider: "openai", model: "" });
102
- });
103
- });
104
-
105
- describe("formatCapabilityString", () => {
106
- it("joins provider and model", () => {
107
- expect(formatCapabilityString("openai", "gpt-4o")).toBe("openai/gpt-4o");
76
+ const spec = readStackSpec(configDir);
77
+ expect(spec).not.toBeNull();
78
+ expect(spec!.version).toBe(2);
108
79
  });
109
80
  });
110
81
 
111
- // ── updateCapability ────────────────────────────────────────────────────
82
+ // ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
112
83
 
113
- describe("updateCapability", () => {
114
- it("updates a single capability key", () => {
115
- writeStackSpec(configDir, makeSpec());
116
- updateCapability(configDir, "llm", "anthropic/claude-sonnet-4");
117
- const read = readStackSpec(configDir);
118
- expect(read).not.toBeNull();
119
- expect(read!.capabilities.llm).toBe("anthropic/claude-sonnet-4");
120
- });
121
-
122
- it("throws when spec is missing", () => {
123
- expect(() => updateCapability(configDir, "llm", "test")).toThrow("stack.yml not found or invalid");
124
- });
125
- });
126
-
127
- // ── stackSpecPath / STACK_SPEC_FILENAME ──────────────────────────────────
128
-
129
- describe("stackSpecPath", () => {
130
- it("returns configDir/stack.yml", () => {
131
- expect(stackSpecPath("/foo/config")).toBe("/foo/config/stack.yml");
132
- });
133
-
134
- it("uses STACK_SPEC_FILENAME constant", () => {
84
+ describe("STACK_SPEC_FILENAME", () => {
85
+ it("is stack.yml", () => {
135
86
  expect(STACK_SPEC_FILENAME).toBe("stack.yml");
136
- expect(stackSpecPath(configDir)).toBe(`${configDir}/${STACK_SPEC_FILENAME}`);
137
- });
138
- });
139
-
140
- // ── writeStackSpec creates directory ─────────────────────────────────────
141
-
142
- describe("writeStackSpec", () => {
143
- it("creates configDir if it does not exist", () => {
144
- const nestedDir = join(configDir, "nested", "deep");
145
- writeStackSpec(nestedDir, makeSpec());
146
- const read = readStackSpec(nestedDir);
147
- expect(read).not.toBeNull();
148
- expect(read!.version).toBe(2);
149
87
  });
150
88
  });