@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.
- package/README.md +4 -2
- package/package.json +11 -3
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +311 -0
- package/src/control-plane/channels.ts +11 -9
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -33
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +148 -73
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +111 -58
- package/src/control-plane/docker.ts +70 -25
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +84 -1
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +190 -292
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +65 -75
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +80 -0
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +247 -51
- package/src/control-plane/registry.ts +404 -54
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +97 -55
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +143 -244
- package/src/control-plane/setup.ts +216 -133
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +75 -60
- package/src/control-plane/spec-to-env.ts +68 -153
- package/src/control-plane/stack-spec.test.ts +22 -84
- package/src/control-plane/stack-spec.ts +9 -89
- package/src/control-plane/types.ts +9 -29
- package/src/control-plane/ui-assets.ts +385 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +102 -56
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/redact-schema.ts +0 -50
- package/src/control-plane/secret-backend.test.ts +0 -359
- package/src/control-plane/secret-backend.ts +0 -322
- 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,
|
|
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(
|
|
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(
|
|
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
|
|
44
|
-
const result = deriveSystemEnvFromSpec(
|
|
45
|
-
expect(result.
|
|
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
|
|
50
|
-
const result = deriveSystemEnvFromSpec(
|
|
51
|
-
|
|
52
|
-
expect(result
|
|
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
|
|
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("
|
|
64
|
-
test("writes
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
mkdirSync(
|
|
93
|
-
|
|
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
|
-
|
|
109
|
+
writeVoiceVars({}, tempDir);
|
|
96
110
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
-
// ──
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
186
|
-
caps.MEMORY_USER_ID = spec.capabilities.memory.userId || "default_user";
|
|
103
|
+
if (Object.keys(vars).length === 0) return;
|
|
187
104
|
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
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,
|
|
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(
|
|
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
|
|
74
|
+
it("returns valid spec for version 2 with no other fields", () => {
|
|
84
75
|
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\n");
|
|
85
|
-
|
|
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
|
-
// ──
|
|
82
|
+
// ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
|
|
112
83
|
|
|
113
|
-
describe("
|
|
114
|
-
it("
|
|
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
|
});
|