@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.11
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 +2 -0
- package/package.json +5 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -12
- 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 +49 -13
- package/src/control-plane/core-assets.ts +63 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/env.ts +4 -1
- package/src/control-plane/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +29 -69
- package/src/control-plane/lifecycle.ts +39 -50
- 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 +8 -3
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +198 -4
- package/src/control-plane/registry.ts +333 -4
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +17 -11
- package/src/control-plane/setup-config.schema.json +3 -3
- package/src/control-plane/setup-status.ts +6 -1
- package/src/control-plane/setup-validation.ts +2 -2
- package/src/control-plane/setup.test.ts +42 -20
- package/src/control-plane/setup.ts +25 -41
- package/src/control-plane/spec-to-env.test.ts +30 -16
- package/src/control-plane/spec-to-env.ts +37 -21
- package/src/control-plane/stack-spec.test.ts +5 -11
- package/src/control-plane/stack-spec.ts +2 -6
- package/src/control-plane/types.ts +0 -22
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +26 -13
- package/src/logger.test.ts +12 -12
- package/src/logger.ts +1 -1
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/audit.ts +0 -41
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -349
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { randomBytes } from "node:crypto";
|
|
11
10
|
import { createLogger } from "../logger.js";
|
|
12
11
|
import {
|
|
13
12
|
PROVIDER_KEY_MAP,
|
|
@@ -18,12 +17,11 @@ import { acquireInstallLock, releaseInstallLock, type InstallLockHandle } from "
|
|
|
18
17
|
import {
|
|
19
18
|
ensureSecrets,
|
|
20
19
|
updateSecretsEnv,
|
|
21
|
-
|
|
20
|
+
patchSecretsEnvFile,
|
|
22
21
|
ensureOpenCodeConfig,
|
|
23
22
|
readStackEnv,
|
|
24
23
|
writeAuthJsonProviderKeys,
|
|
25
24
|
} from "./secrets.js";
|
|
26
|
-
import { ensureOpenCodeSystemConfig } from "./core-assets.js";
|
|
27
25
|
import { createState } from "./lifecycle.js";
|
|
28
26
|
import { writeStackSpec } from "./stack-spec.js";
|
|
29
27
|
import { writeVoiceVars } from "./spec-to-env.js";
|
|
@@ -70,7 +68,12 @@ export type SetupSpec = {
|
|
|
70
68
|
embedding?: { provider: string; model: string; dims: number; baseUrl?: string };
|
|
71
69
|
tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
|
|
72
70
|
stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
|
|
73
|
-
|
|
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).
|
|
75
|
+
*/
|
|
76
|
+
security: { uiLoginPassword: string };
|
|
74
77
|
owner?: { name?: string; email?: string };
|
|
75
78
|
connections: SetupConnection[];
|
|
76
79
|
channelCredentials?: Record<string, Record<string, string>>;
|
|
@@ -94,8 +97,8 @@ export function buildSecretsFromSetup(
|
|
|
94
97
|
const updates: Record<string, string> = {};
|
|
95
98
|
const ownerName = (owner?.name?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
96
99
|
const ownerEmail = (owner?.email?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
97
|
-
if (ownerName) updates.
|
|
98
|
-
if (ownerEmail) updates.
|
|
100
|
+
if (ownerName) updates.OP_OWNER_NAME = ownerName;
|
|
101
|
+
if (ownerEmail) updates.OP_OWNER_EMAIL = ownerEmail;
|
|
99
102
|
void connections;
|
|
100
103
|
return updates;
|
|
101
104
|
}
|
|
@@ -121,41 +124,26 @@ export function buildAuthJsonFromSetup(
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
/**
|
|
124
|
-
* Build the system-secret env update.
|
|
127
|
+
* Build the system-secret env update for the wizard / CLI install path.
|
|
125
128
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* or a previous run wrote it blank; either way silent rotation breaks the
|
|
132
|
-
* running stack.
|
|
133
|
-
* - the key is absent entirely → generate a fresh token (first install).
|
|
129
|
+
* Phase 4 of the auth/proxy refactor collapsed the legacy
|
|
130
|
+
* `OP_UI_TOKEN` / `OP_ASSISTANT_TOKEN` pair into a single operator login
|
|
131
|
+
* secret (`OP_UI_LOGIN_PASSWORD`). The browser stores the cookie value =
|
|
132
|
+
* password; `requireAdmin()` compares the cookie against
|
|
133
|
+
* `process.env.OP_UI_LOGIN_PASSWORD` via the existing `safeTokenCompare`.
|
|
134
134
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
135
|
+
* `OP_OPENCODE_PASSWORD` is generated by `ensureSystemSecrets()` on first
|
|
136
|
+
* run and persists across reruns — it is not regenerated here.
|
|
137
|
+
*
|
|
138
|
+
* `existingSystemEnv` is unused now but the parameter is kept so callers
|
|
139
|
+
* compile unchanged. It can be removed in a follow-up cleanup.
|
|
137
140
|
*/
|
|
138
141
|
export function buildSystemSecretsFromSetup(
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
uiLoginPassword: string,
|
|
143
|
+
_existingSystemEnv: Record<string, string> = {}
|
|
141
144
|
): Record<string, string> {
|
|
142
|
-
const hasKey = Object.prototype.hasOwnProperty.call(existingSystemEnv, "OP_ASSISTANT_TOKEN");
|
|
143
|
-
const existing = existingSystemEnv.OP_ASSISTANT_TOKEN;
|
|
144
|
-
let token: string;
|
|
145
|
-
if (existing) {
|
|
146
|
-
token = existing;
|
|
147
|
-
} else if (hasKey) {
|
|
148
|
-
throw new Error(
|
|
149
|
-
"OP_ASSISTANT_TOKEN is present but blank in config/stack/stack.env. " +
|
|
150
|
-
"Refusing to silently rotate the token (it would break the running stack). " +
|
|
151
|
-
"Restore the previous value or remove the line entirely to generate a fresh one.",
|
|
152
|
-
);
|
|
153
|
-
} else {
|
|
154
|
-
token = randomBytes(32).toString("hex");
|
|
155
|
-
}
|
|
156
145
|
return {
|
|
157
|
-
|
|
158
|
-
OP_ASSISTANT_TOKEN: token,
|
|
146
|
+
OP_UI_LOGIN_PASSWORD: uiLoginPassword,
|
|
159
147
|
};
|
|
160
148
|
}
|
|
161
149
|
|
|
@@ -205,7 +193,7 @@ export async function performSetup(
|
|
|
205
193
|
if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
|
|
206
194
|
|
|
207
195
|
const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
|
|
208
|
-
const state = opts?.state ?? createState(
|
|
196
|
+
const state = opts?.state ?? createState();
|
|
209
197
|
|
|
210
198
|
// Acquire install lock to prevent two concurrent setup runs from racing on
|
|
211
199
|
// the same config directory. The lock lives in stateDir so it is co-located
|
|
@@ -238,7 +226,7 @@ export async function performSetup(
|
|
|
238
226
|
}
|
|
239
227
|
}
|
|
240
228
|
updateSecretsEnv(state, updates);
|
|
241
|
-
|
|
229
|
+
patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
|
|
242
230
|
// Provider API keys land in OpenCode's auth.json (bind-mounted into
|
|
243
231
|
// the assistant container) — never in stack.env.
|
|
244
232
|
writeAuthJsonProviderKeys(state, providerKeys);
|
|
@@ -248,9 +236,6 @@ export async function performSetup(
|
|
|
248
236
|
return { ok: false, error: `Failed to persist setup outputs: ${message}` };
|
|
249
237
|
}
|
|
250
238
|
|
|
251
|
-
state.adminToken = security.adminToken;
|
|
252
|
-
state.assistantToken = readStackEnv(state.stackDir).OP_ASSISTANT_TOKEN ?? state.assistantToken;
|
|
253
|
-
|
|
254
239
|
// Everything from here through the OP_SETUP_COMPLETE write is wrapped in a
|
|
255
240
|
// single try/catch so that a disk-full or permission-denied mid-way returns a
|
|
256
241
|
// clean error rather than leaving a broken half-installed ~/.openpalm/.
|
|
@@ -325,7 +310,6 @@ export async function performSetup(
|
|
|
325
310
|
}
|
|
326
311
|
|
|
327
312
|
ensureOpenCodeConfig();
|
|
328
|
-
ensureOpenCodeSystemConfig();
|
|
329
313
|
|
|
330
314
|
// Seed default automation into the AKM stash. Idempotent — existing files
|
|
331
315
|
// are left alone so user edits survive re-install and upgrade.
|
|
@@ -1,11 +1,9 @@
|
|
|
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
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,33 +16,49 @@ afterEach(() => {
|
|
|
18
16
|
|
|
19
17
|
describe("deriveSystemEnvFromSpec", () => {
|
|
20
18
|
test("produces OP_HOME", () => {
|
|
21
|
-
const result = deriveSystemEnvFromSpec(
|
|
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(
|
|
24
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
27
25
|
expect(result.OP_ASSISTANT_PORT).toBe("3800");
|
|
28
|
-
|
|
26
|
+
});
|
|
27
|
+
|
|
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();
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
test("does not include the retired memory service port", () => {
|
|
32
|
-
const result = deriveSystemEnvFromSpec(
|
|
34
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
33
35
|
const retired = "OP_" + "MEMORY_PORT";
|
|
34
36
|
expect(result[retired]).toBeUndefined();
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
test("does not include LLM provider in system env", () => {
|
|
38
|
-
const result = deriveSystemEnvFromSpec(
|
|
40
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
39
41
|
expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
|
|
40
42
|
expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
test("does not include removed feature flags", () => {
|
|
44
|
-
const result = deriveSystemEnvFromSpec(
|
|
46
|
+
const result = deriveSystemEnvFromSpec("/home/op");
|
|
45
47
|
expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
|
|
46
48
|
expect(result.OP_ADMIN_ENABLED).toBeUndefined();
|
|
47
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
|
+
});
|
|
48
62
|
});
|
|
49
63
|
|
|
50
64
|
describe("writeVoiceVars", () => {
|
|
@@ -57,9 +71,9 @@ describe("writeVoiceVars", () => {
|
|
|
57
71
|
}, tempDir);
|
|
58
72
|
|
|
59
73
|
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
60
|
-
expect(content).toContain("
|
|
61
|
-
expect(content).toContain("
|
|
62
|
-
expect(content).toContain("
|
|
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");
|
|
63
77
|
});
|
|
64
78
|
|
|
65
79
|
test("writes STT vars to stack.env", () => {
|
|
@@ -71,9 +85,9 @@ describe("writeVoiceVars", () => {
|
|
|
71
85
|
}, tempDir);
|
|
72
86
|
|
|
73
87
|
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
74
|
-
expect(content).toContain("
|
|
75
|
-
expect(content).toContain("
|
|
76
|
-
expect(content).toContain("
|
|
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");
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
test("creates stack.env if it does not exist", () => {
|
|
@@ -84,7 +98,7 @@ describe("writeVoiceVars", () => {
|
|
|
84
98
|
}, tempDir);
|
|
85
99
|
|
|
86
100
|
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
87
|
-
expect(content).toContain("
|
|
101
|
+
expect(content).toContain("OP_TTS_BASE_URL=https://tts.example.com/v1");
|
|
88
102
|
});
|
|
89
103
|
|
|
90
104
|
test("is a no-op when no vars are provided", () => {
|
|
@@ -5,22 +5,16 @@
|
|
|
5
5
|
* Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { StackSpec } from "./stack-spec.js";
|
|
9
8
|
import { SPEC_DEFAULTS } from "./stack-spec.js";
|
|
10
9
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
10
|
import { mergeEnvContent } from "./env.js";
|
|
11
|
+
import { resolveOperatorIds } from "./operator-ids.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Derive the system.env key-value pairs from the StackSpec.
|
|
15
15
|
* Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
|
|
16
16
|
*/
|
|
17
|
-
export function deriveSystemEnvFromSpec(
|
|
18
|
-
spec: StackSpec,
|
|
19
|
-
homeDir: string,
|
|
20
|
-
): Record<string, string> {
|
|
21
|
-
const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
|
|
22
|
-
const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
|
|
23
|
-
|
|
17
|
+
export function deriveSystemEnvFromSpec(homeDir: string): Record<string, string> {
|
|
24
18
|
const ports = SPEC_DEFAULTS.ports;
|
|
25
19
|
const image = SPEC_DEFAULTS.image;
|
|
26
20
|
|
|
@@ -28,21 +22,27 @@ export function deriveSystemEnvFromSpec(
|
|
|
28
22
|
|
|
29
23
|
// Paths
|
|
30
24
|
result["OP_HOME"] = homeDir;
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
|
33
34
|
// Image
|
|
34
35
|
result["OP_IMAGE_NAMESPACE"] = image.namespace;
|
|
35
36
|
result["OP_IMAGE_TAG"] = image.tag;
|
|
36
37
|
|
|
37
|
-
// 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.
|
|
38
41
|
result["OP_ASSISTANT_PORT"] = String(ports.assistant);
|
|
39
42
|
result["OP_ADMIN_PORT"] = String(ports.admin);
|
|
40
43
|
result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
|
|
41
|
-
result["OP_GUARDIAN_PORT"] = String(ports.guardian);
|
|
42
44
|
result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
|
|
43
45
|
|
|
44
|
-
void spec; // spec reserved for future use; ports/image come from SPEC_DEFAULTS
|
|
45
|
-
|
|
46
46
|
return result;
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -51,12 +51,18 @@ export function deriveSystemEnvFromSpec(
|
|
|
51
51
|
export type VoiceVarsConfig = {
|
|
52
52
|
tts?: {
|
|
53
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;
|
|
54
58
|
baseURL?: string;
|
|
55
59
|
model?: string;
|
|
56
60
|
voice?: string;
|
|
57
61
|
};
|
|
58
62
|
stt?: {
|
|
59
63
|
enabled?: boolean;
|
|
64
|
+
engine?: string;
|
|
65
|
+
provider?: string;
|
|
60
66
|
baseURL?: string;
|
|
61
67
|
model?: string;
|
|
62
68
|
language?: string;
|
|
@@ -65,23 +71,33 @@ export type VoiceVarsConfig = {
|
|
|
65
71
|
|
|
66
72
|
/**
|
|
67
73
|
* Write TTS/STT env vars to stack.env for the voice channel container.
|
|
68
|
-
*
|
|
74
|
+
* `engine` always writes (even if it's the only field) so picking an
|
|
75
|
+
* engine without filling in URL/model still persists.
|
|
69
76
|
*/
|
|
70
77
|
export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
|
|
71
78
|
const stackEnvPath = `${stackDir}/stack.env`;
|
|
72
79
|
const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
73
80
|
const vars: Record<string, string> = {};
|
|
74
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.
|
|
75
87
|
const { tts, stt } = config;
|
|
76
88
|
if (tts?.enabled !== false) {
|
|
77
|
-
if (tts?.
|
|
78
|
-
if (tts?.
|
|
79
|
-
if (tts?.
|
|
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;
|
|
80
94
|
}
|
|
81
95
|
if (stt?.enabled !== false) {
|
|
82
|
-
if (stt?.
|
|
83
|
-
if (stt?.
|
|
84
|
-
if (stt?.
|
|
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;
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
if (Object.keys(vars).length === 0) return;
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
readStackSpec,
|
|
12
12
|
writeStackSpec,
|
|
13
13
|
STACK_SPEC_FILENAME,
|
|
14
|
-
stackSpecPath,
|
|
15
14
|
} from "./stack-spec.js";
|
|
16
15
|
import type { StackSpec } from "./stack-spec.js";
|
|
17
16
|
|
|
@@ -40,9 +39,8 @@ describe("readStackSpec / writeStackSpec round-trip", () => {
|
|
|
40
39
|
it("writes to the canonical filename", () => {
|
|
41
40
|
writeStackSpec(configDir, MINIMAL_SPEC);
|
|
42
41
|
const expectedPath = join(configDir, STACK_SPEC_FILENAME);
|
|
43
|
-
|
|
44
|
-
expect(
|
|
45
|
-
expect(stackSpecPath(configDir)).toBe(expectedPath);
|
|
42
|
+
expect(expectedPath).toBe(join(configDir, "stack.yml"));
|
|
43
|
+
expect(readStackSpec(configDir)).not.toBeNull();
|
|
46
44
|
});
|
|
47
45
|
|
|
48
46
|
it("ignores legacy capabilities fields on read", () => {
|
|
@@ -81,14 +79,10 @@ describe("readStackSpec edge cases", () => {
|
|
|
81
79
|
});
|
|
82
80
|
});
|
|
83
81
|
|
|
84
|
-
// ──
|
|
85
|
-
|
|
86
|
-
describe("stackSpecPath", () => {
|
|
87
|
-
it("returns stackDir/stack.yml", () => {
|
|
88
|
-
expect(stackSpecPath("/foo/config/stack")).toBe("/foo/config/stack/stack.yml");
|
|
89
|
-
});
|
|
82
|
+
// ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
|
|
90
83
|
|
|
91
|
-
|
|
84
|
+
describe("STACK_SPEC_FILENAME", () => {
|
|
85
|
+
it("is stack.yml", () => {
|
|
92
86
|
expect(STACK_SPEC_FILENAME).toBe("stack.yml");
|
|
93
87
|
});
|
|
94
88
|
});
|
|
@@ -36,14 +36,10 @@ export const SPEC_DEFAULTS = {
|
|
|
36
36
|
|
|
37
37
|
// ── Read / Write ────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
export function stackSpecPath(configDir: string): string {
|
|
40
|
-
return `${configDir}/${STACK_SPEC_FILENAME}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
export function writeStackSpec(configDir: string, spec: StackSpec): void {
|
|
44
40
|
mkdirSync(configDir, { recursive: true });
|
|
45
41
|
const content = yamlStringify(spec, { indent: 2 });
|
|
46
|
-
writeFileSync(
|
|
42
|
+
writeFileSync(`${configDir}/${STACK_SPEC_FILENAME}`, content);
|
|
47
43
|
}
|
|
48
44
|
|
|
49
45
|
/**
|
|
@@ -51,7 +47,7 @@ export function writeStackSpec(configDir: string, spec: StackSpec): void {
|
|
|
51
47
|
* Only the version field is checked; legacy capability fields are ignored.
|
|
52
48
|
*/
|
|
53
49
|
export function readStackSpec(configDir: string): StackSpec | null {
|
|
54
|
-
const path =
|
|
50
|
+
const path = `${configDir}/${STACK_SPEC_FILENAME}`;
|
|
55
51
|
if (!existsSync(path)) return null;
|
|
56
52
|
|
|
57
53
|
let raw: unknown;
|
|
@@ -8,15 +8,8 @@ export type CoreServiceName =
|
|
|
8
8
|
| "assistant"
|
|
9
9
|
| "guardian";
|
|
10
10
|
|
|
11
|
-
export type OptionalServiceName = never;
|
|
12
|
-
|
|
13
11
|
export type AccessScope = "host" | "lan";
|
|
14
12
|
export type CallerType = "assistant" | "cli" | "ui" | "system" | "test" | "unknown";
|
|
15
|
-
export type AuditContext = {
|
|
16
|
-
actor: string;
|
|
17
|
-
requestId?: string;
|
|
18
|
-
callerType?: CallerType;
|
|
19
|
-
};
|
|
20
13
|
|
|
21
14
|
/** Info about a discovered channel */
|
|
22
15
|
export type ChannelInfo = {
|
|
@@ -24,16 +17,6 @@ export type ChannelInfo = {
|
|
|
24
17
|
ymlPath: string;
|
|
25
18
|
};
|
|
26
19
|
|
|
27
|
-
export type AuditEntry = {
|
|
28
|
-
at: string;
|
|
29
|
-
requestId: string;
|
|
30
|
-
actor: string;
|
|
31
|
-
callerType: CallerType;
|
|
32
|
-
action: string;
|
|
33
|
-
args: Record<string, unknown>;
|
|
34
|
-
ok: boolean;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
20
|
export type ArtifactMeta = {
|
|
38
21
|
name: string;
|
|
39
22
|
sha256: string;
|
|
@@ -42,8 +25,6 @@ export type ArtifactMeta = {
|
|
|
42
25
|
};
|
|
43
26
|
|
|
44
27
|
export type ControlPlaneState = {
|
|
45
|
-
adminToken: string;
|
|
46
|
-
assistantToken: string;
|
|
47
28
|
homeDir: string;
|
|
48
29
|
configDir: string;
|
|
49
30
|
stashDir: string; // homeDir/stash
|
|
@@ -56,7 +37,6 @@ export type ControlPlaneState = {
|
|
|
56
37
|
compose: string;
|
|
57
38
|
};
|
|
58
39
|
artifactMeta: ArtifactMeta[];
|
|
59
|
-
audit: AuditEntry[];
|
|
60
40
|
};
|
|
61
41
|
|
|
62
42
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
@@ -69,5 +49,3 @@ export const CORE_SERVICES: CoreServiceName[] = [
|
|
|
69
49
|
"guardian",
|
|
70
50
|
];
|
|
71
51
|
|
|
72
|
-
export const OPTIONAL_SERVICES: OptionalServiceName[] = [];
|
|
73
|
-
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
14
|
existsSync, mkdirSync, readdirSync, copyFileSync,
|
|
15
|
-
writeFileSync, rmSync, realpathSync, renameSync,
|
|
15
|
+
readFileSync, writeFileSync, rmSync, realpathSync, renameSync,
|
|
16
16
|
} from 'node:fs';
|
|
17
17
|
import { join, dirname, relative } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
@@ -156,7 +156,13 @@ export function resolveLocalUiBuild(): string | null {
|
|
|
156
156
|
() => process.env.OPENPALM_REPO_ROOT
|
|
157
157
|
? join(process.env.OPENPALM_REPO_ROOT, 'packages', 'ui', 'build')
|
|
158
158
|
: null,
|
|
159
|
-
// 2.
|
|
159
|
+
// 2. Electron extraResources — ui-build/ is placed alongside the asar
|
|
160
|
+
() => {
|
|
161
|
+
const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
|
162
|
+
if (!rp) return null;
|
|
163
|
+
return join(rp, 'ui-build');
|
|
164
|
+
},
|
|
165
|
+
// 3. Relative to this source file (dev / bun run)
|
|
160
166
|
() => {
|
|
161
167
|
const meta = fileURLToPath(import.meta.url);
|
|
162
168
|
if (meta.startsWith('/$bunfs/')) return null;
|
|
@@ -164,7 +170,7 @@ export function resolveLocalUiBuild(): string | null {
|
|
|
164
170
|
const candidate = join(dirname(meta), '..', '..', '..', '..', 'packages', 'ui', 'build');
|
|
165
171
|
return existsSync(join(candidate, 'index.js')) ? candidate : null;
|
|
166
172
|
},
|
|
167
|
-
//
|
|
173
|
+
// 4. Relative to compiled binary / Electron executable
|
|
168
174
|
() => {
|
|
169
175
|
const binDir = dirname(realpathSync(process.execPath));
|
|
170
176
|
const candidate = join(binDir, '..', '..', '..', 'packages', 'ui', 'build');
|
|
@@ -173,17 +179,36 @@ export function resolveLocalUiBuild(): string | null {
|
|
|
173
179
|
);
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
function readUiVersionFile(dir: string): string | null {
|
|
183
|
+
try { return readFileSync(join(dir, 'version.txt'), 'utf-8').trim(); } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
|
|
176
186
|
/**
|
|
177
187
|
* Resolve the best available UI build directory at runtime.
|
|
178
188
|
*
|
|
179
189
|
* Priority:
|
|
180
|
-
* 1. OP_HOME/state/ui/ —
|
|
181
|
-
* 2.
|
|
190
|
+
* 1. OP_HOME/state/ui/ — if its version.txt is NEWER than the bundled build
|
|
191
|
+
* 2. Bundled / local build (Electron extraResources, source checkout)
|
|
192
|
+
* 3. OP_HOME/state/ui/ — fallback when no bundled build exists
|
|
193
|
+
*
|
|
194
|
+
* This means GitHub-downloaded updates are applied automatically (disk wins
|
|
195
|
+
* when newer), but a fresh AppImage install always works without a download.
|
|
182
196
|
*/
|
|
183
197
|
export function resolveUiBuildDir(): string {
|
|
184
198
|
const stateBuild = join(resolveStateDir(), 'ui');
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
const localBuild = resolveLocalUiBuild();
|
|
200
|
+
|
|
201
|
+
if (existsSync(join(stateBuild, 'index.js')) && localBuild) {
|
|
202
|
+
const diskVer = readUiVersionFile(stateBuild);
|
|
203
|
+
const bundledVer = readUiVersionFile(localBuild);
|
|
204
|
+
if (diskVer && bundledVer && compareVersionTags(diskVer, bundledVer) > 0) {
|
|
205
|
+
return stateBuild;
|
|
206
|
+
}
|
|
207
|
+
return localBuild;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (localBuild) return localBuild;
|
|
211
|
+
return stateBuild;
|
|
187
212
|
}
|
|
188
213
|
|
|
189
214
|
/**
|
|
@@ -216,14 +241,21 @@ function parseChecksumsFile(content: string): Map<string, string> {
|
|
|
216
241
|
return map;
|
|
217
242
|
}
|
|
218
243
|
|
|
219
|
-
export
|
|
244
|
+
export function readCurrentUiBuildVersion(stateDir: string): string | null {
|
|
245
|
+
const versionFile = join(stateDir, 'ui', 'version.txt');
|
|
246
|
+
if (!existsSync(versionFile)) return null;
|
|
247
|
+
return readFileSync(versionFile, 'utf-8').trim() || null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function seedUiBuild(repoRef: string, stateDir: string, options?: { forceRemote?: boolean }): Promise<void> {
|
|
220
251
|
const uiDir = join(stateDir, 'ui');
|
|
221
252
|
mkdirSync(uiDir, { recursive: true });
|
|
222
253
|
|
|
223
|
-
const local = resolveLocalUiBuild();
|
|
254
|
+
const local = options?.forceRemote ? null : resolveLocalUiBuild();
|
|
224
255
|
if (local) {
|
|
225
256
|
logger.debug('seeding UI build from local source', { src: local });
|
|
226
257
|
copyTree(local, uiDir);
|
|
258
|
+
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
227
259
|
return;
|
|
228
260
|
}
|
|
229
261
|
|
|
@@ -258,8 +290,12 @@ export async function seedUiBuild(repoRef: string, stateDir: string): Promise<vo
|
|
|
258
290
|
|
|
259
291
|
writeFileSync(tmpTar, tarData);
|
|
260
292
|
|
|
293
|
+
// Clear stale files before extracting so old build files don't persist
|
|
294
|
+
rmSync(uiDir, { recursive: true, force: true });
|
|
295
|
+
mkdirSync(uiDir, { recursive: true });
|
|
261
296
|
// Cross-platform extraction via the `tar` npm package — no shell dependency
|
|
262
297
|
await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
|
|
298
|
+
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
263
299
|
} finally {
|
|
264
300
|
rmSync(tmpTar, { force: true });
|
|
265
301
|
}
|
|
@@ -15,7 +15,7 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
15
15
|
// Stack-scoped env keys that must always exist and carry a non-empty value
|
|
16
16
|
// for the platform to boot. Keep this list small — anything optional
|
|
17
17
|
// belongs in the warning bucket instead.
|
|
18
|
-
const REQUIRED_STACK_KEYS = ["
|
|
18
|
+
const REQUIRED_STACK_KEYS = ["OP_UI_LOGIN_PASSWORD"] as const;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Validate the live configuration files.
|