@openpalm/lib 0.9.9 → 0.10.1
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 +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +158 -886
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- package/src/control-plane/staging.ts +0 -399
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared setup orchestration for the OpenPalm control plane.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* so that both the CLI setup wizard and the admin UI can call `performSetup()`.
|
|
6
|
-
*
|
|
4
|
+
* Both the CLI setup wizard and the admin UI call `performSetup()`.
|
|
7
5
|
* This module does NOT include Docker operations (compose up, image pull, etc.)
|
|
8
6
|
* — those happen separately in the caller after setup completes.
|
|
9
7
|
*/
|
|
10
8
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
11
10
|
import { createLogger } from "../logger.js";
|
|
12
11
|
import {
|
|
13
12
|
PROVIDER_KEY_MAP,
|
|
@@ -15,34 +14,26 @@ import {
|
|
|
15
14
|
OLLAMA_INSTACK_URL,
|
|
16
15
|
} from "../provider-constants.js";
|
|
17
16
|
import { mergeEnvContent } from "./env.js";
|
|
18
|
-
import {
|
|
17
|
+
import { ensureHomeDirs } from "./home.js";
|
|
19
18
|
import {
|
|
20
19
|
ensureSecrets,
|
|
21
20
|
updateSecretsEnv,
|
|
21
|
+
updateSystemSecretsEnv,
|
|
22
22
|
ensureOpenCodeConfig,
|
|
23
|
+
readStackEnv,
|
|
23
24
|
} from "./secrets.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { writeMemoryConfig } from "./memory-config.js";
|
|
27
|
-
import { ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, ensureMemoryDir } from "./core-assets.js";
|
|
28
|
-
import { applyInstall, createState, writeSetupTokenFile } from "./lifecycle.js";
|
|
25
|
+
import { ensureOpenCodeSystemConfig, ensureMemoryDir } from "./core-assets.js";
|
|
26
|
+
import { createState, writeSetupTokenFile } from "./lifecycle.js";
|
|
29
27
|
import { writeStackSpec } from "./stack-spec.js";
|
|
30
|
-
import type { StackSpec } from "./stack-spec.js";
|
|
31
|
-
import {
|
|
32
|
-
import type {
|
|
33
|
-
import
|
|
34
|
-
import
|
|
28
|
+
import type { StackSpec, StackSpecCapabilities } from "./stack-spec.js";
|
|
29
|
+
import { writeCapabilityVars } from "./spec-to-env.js";
|
|
30
|
+
import type { ControlPlaneState } from "./types.js";
|
|
31
|
+
import { validateSetupSpec } from "./setup-validation.js";
|
|
32
|
+
import { listEnabledAddonIds } from "./registry.js";
|
|
33
|
+
export { validateSetupSpec } from "./setup-validation.js";
|
|
35
34
|
|
|
36
35
|
const logger = createLogger("setup");
|
|
37
36
|
|
|
38
|
-
/** Apply Ollama in-stack URL override to connections when Ollama is enabled. */
|
|
39
|
-
function resolveOllamaUrls(connections: SetupConnection[], ollamaEnabled: boolean): SetupConnection[] {
|
|
40
|
-
if (!ollamaEnabled) return connections;
|
|
41
|
-
return connections.map((c) =>
|
|
42
|
-
c.provider === "ollama" ? { ...c, baseUrl: OLLAMA_INSTACK_URL } : c
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
37
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
47
38
|
|
|
48
39
|
export type SetupConnection = {
|
|
@@ -53,644 +44,112 @@ export type SetupConnection = {
|
|
|
53
44
|
apiKey: string;
|
|
54
45
|
};
|
|
55
46
|
|
|
56
|
-
export type SetupAssignments = {
|
|
57
|
-
llm: { connectionId: string; model: string; smallModel?: string };
|
|
58
|
-
embeddings: { connectionId: string; model: string; embeddingDims?: number };
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export type SetupInput = {
|
|
62
|
-
adminToken: string;
|
|
63
|
-
ownerName?: string;
|
|
64
|
-
ownerEmail?: string;
|
|
65
|
-
memoryUserId: string;
|
|
66
|
-
ollamaEnabled: boolean;
|
|
67
|
-
connections: SetupConnection[];
|
|
68
|
-
assignments: SetupAssignments;
|
|
69
|
-
voice?: {
|
|
70
|
-
tts?: string; // e.g. 'kokoro', 'piper', 'openai-tts', 'browser-tts', null
|
|
71
|
-
stt?: string; // e.g. 'whisper-local', 'openai-stt', 'browser-stt', null
|
|
72
|
-
};
|
|
73
|
-
channels?: string[]; // e.g. ['chat', 'discord', 'api']
|
|
74
|
-
services?: {
|
|
75
|
-
admin?: boolean;
|
|
76
|
-
openviking?: boolean;
|
|
77
|
-
ollama?: boolean;
|
|
78
|
-
};
|
|
79
|
-
};
|
|
80
|
-
|
|
81
47
|
export type SetupResult = {
|
|
82
48
|
ok: boolean;
|
|
83
49
|
error?: string;
|
|
84
|
-
/** Services that should be started after setup. */
|
|
85
50
|
started?: string[];
|
|
86
51
|
};
|
|
87
52
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
version: 1;
|
|
92
|
-
owner?: { name?: string; email?: string };
|
|
53
|
+
export type SetupSpec = {
|
|
54
|
+
version: 2;
|
|
55
|
+
capabilities: StackSpecCapabilities;
|
|
93
56
|
security: { adminToken: string };
|
|
57
|
+
owner?: { name?: string; email?: string };
|
|
94
58
|
connections: SetupConnection[];
|
|
95
|
-
|
|
96
|
-
memory?: { userId?: string };
|
|
97
|
-
channels?: Record<string, boolean | ChannelCredentials>;
|
|
98
|
-
services?: Record<string, boolean | ServiceConfig>;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
export type SetupConfigAssignments = {
|
|
102
|
-
llm: { connectionId: string; model: string; smallModel?: string };
|
|
103
|
-
embeddings: { connectionId: string; model: string; embeddingDims?: number };
|
|
104
|
-
tts?: { engine: string; connectionId?: string; model?: string } | string | null;
|
|
105
|
-
stt?: { engine: string; connectionId?: string; model?: string } | string | null;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
export type ChannelCredentials = {
|
|
109
|
-
enabled?: boolean;
|
|
110
|
-
botToken?: string;
|
|
111
|
-
applicationId?: string;
|
|
112
|
-
registerCommands?: boolean;
|
|
113
|
-
allowedGuilds?: string;
|
|
114
|
-
allowedRoles?: string;
|
|
115
|
-
allowedUsers?: string;
|
|
116
|
-
blockedUsers?: string;
|
|
117
|
-
slackBotToken?: string;
|
|
118
|
-
slackAppToken?: string;
|
|
119
|
-
allowedChannels?: string;
|
|
120
|
-
[key: string]: unknown;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
export type ServiceConfig = {
|
|
124
|
-
enabled: boolean;
|
|
125
|
-
[key: string]: unknown;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
export type DetectedProvider = {
|
|
129
|
-
provider: string;
|
|
130
|
-
url: string;
|
|
131
|
-
available: boolean;
|
|
59
|
+
channelCredentials?: Record<string, Record<string, string>>;
|
|
132
60
|
};
|
|
133
61
|
|
|
134
|
-
// ── Validation ───────────────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
/** Safe env var key pattern: uppercase alphanumeric + underscores, starting with a letter. */
|
|
137
|
-
const SAFE_ENV_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
138
|
-
|
|
139
|
-
/** Valid connection ID pattern: starts with letter or digit, allows A-Z, a-z, 0-9, _, -. */
|
|
140
|
-
const CONNECTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
141
|
-
|
|
142
|
-
/** Providers that are valid for setup wizard connections. */
|
|
143
|
-
const WIZARD_PROVIDERS = new Set([
|
|
144
|
-
"openai", "anthropic", "ollama", "groq", "together",
|
|
145
|
-
"mistral", "deepseek", "xai", "lmstudio", "model-runner",
|
|
146
|
-
"ollama-instack", "google", "huggingface",
|
|
147
|
-
]);
|
|
148
|
-
|
|
149
|
-
// ── Shared Validation Helpers ────────────────────────────────────────────
|
|
150
|
-
|
|
151
|
-
/** Validate a connections array. Pushes errors to the provided array. */
|
|
152
|
-
function validateConnectionsArray(
|
|
153
|
-
connections: unknown,
|
|
154
|
-
errors: string[]
|
|
155
|
-
): void {
|
|
156
|
-
if (!Array.isArray(connections) || connections.length === 0) {
|
|
157
|
-
errors.push("connections array is required and must be non-empty");
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const seenIds = new Set<string>();
|
|
161
|
-
for (let i = 0; i < connections.length; i++) {
|
|
162
|
-
const c = connections[i];
|
|
163
|
-
if (typeof c !== "object" || c === null) {
|
|
164
|
-
errors.push(`connections[${i}] must be an object`);
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
const conn = c as Record<string, unknown>;
|
|
168
|
-
const id = typeof conn.id === "string" ? conn.id.trim() : "";
|
|
169
|
-
const provider = typeof conn.provider === "string" ? conn.provider.trim() : "";
|
|
170
|
-
|
|
171
|
-
if (!id) {
|
|
172
|
-
errors.push(`connections[${i}].id is required`);
|
|
173
|
-
} else if (!CONNECTION_ID_RE.test(id)) {
|
|
174
|
-
errors.push(`connections[${i}].id must start with a letter or digit (allowed: A-Z, a-z, 0-9, _, -)`);
|
|
175
|
-
} else if (seenIds.has(id)) {
|
|
176
|
-
errors.push(`Duplicate connection ID: ${id}`);
|
|
177
|
-
} else {
|
|
178
|
-
seenIds.add(id);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const name = typeof conn.name === "string" ? conn.name.trim() : "";
|
|
182
|
-
if (!name) {
|
|
183
|
-
errors.push(`connections[${i}].name is required`);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!provider) {
|
|
187
|
-
errors.push(`connections[${i}].provider is required`);
|
|
188
|
-
} else if (!WIZARD_PROVIDERS.has(provider)) {
|
|
189
|
-
errors.push(`connections[${i}].provider "${provider}" is outside wizard scope`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Validate assignments block (llm + embeddings) and cross-validate
|
|
196
|
-
* connectionIds against the connections array. Pushes errors to the
|
|
197
|
-
* provided array.
|
|
198
|
-
*/
|
|
199
|
-
function validateAssignmentsBlock(
|
|
200
|
-
assignments: unknown,
|
|
201
|
-
connections: unknown,
|
|
202
|
-
errors: string[]
|
|
203
|
-
): void {
|
|
204
|
-
if (typeof assignments !== "object" || assignments === null) {
|
|
205
|
-
errors.push("assignments object is required");
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const assignmentsObj = assignments as Record<string, unknown>;
|
|
210
|
-
const llm = assignmentsObj.llm;
|
|
211
|
-
const embeddings = assignmentsObj.embeddings;
|
|
212
|
-
const preAssignmentLength = errors.length;
|
|
213
|
-
|
|
214
|
-
if (typeof llm !== "object" || llm === null) {
|
|
215
|
-
errors.push("assignments.llm is required");
|
|
216
|
-
} else {
|
|
217
|
-
const llmObj = llm as Record<string, unknown>;
|
|
218
|
-
if (!llmObj.connectionId || typeof llmObj.connectionId !== "string") {
|
|
219
|
-
errors.push("assignments.llm.connectionId is required");
|
|
220
|
-
}
|
|
221
|
-
if (!llmObj.model || typeof llmObj.model !== "string") {
|
|
222
|
-
errors.push("assignments.llm.model is required");
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (typeof embeddings !== "object" || embeddings === null) {
|
|
227
|
-
errors.push("assignments.embeddings is required");
|
|
228
|
-
} else {
|
|
229
|
-
const embObj = embeddings as Record<string, unknown>;
|
|
230
|
-
if (!embObj.connectionId || typeof embObj.connectionId !== "string") {
|
|
231
|
-
errors.push("assignments.embeddings.connectionId is required");
|
|
232
|
-
}
|
|
233
|
-
if (!embObj.model || typeof embObj.model !== "string") {
|
|
234
|
-
errors.push("assignments.embeddings.model is required");
|
|
235
|
-
}
|
|
236
|
-
if (
|
|
237
|
-
embObj.embeddingDims !== undefined &&
|
|
238
|
-
(typeof embObj.embeddingDims !== "number" ||
|
|
239
|
-
!Number.isInteger(embObj.embeddingDims) ||
|
|
240
|
-
embObj.embeddingDims < 1)
|
|
241
|
-
) {
|
|
242
|
-
errors.push("assignments.embeddings.embeddingDims must be a positive integer");
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Cross-validate: assignment connectionIds must reference a connection.
|
|
247
|
-
// Only run when no new assignment errors were added above.
|
|
248
|
-
if (Array.isArray(connections) && errors.length === preAssignmentLength) {
|
|
249
|
-
const connectionIds = new Set(
|
|
250
|
-
(connections as Array<Record<string, unknown>>).map(
|
|
251
|
-
(c) => typeof c.id === "string" ? c.id.trim() : ""
|
|
252
|
-
)
|
|
253
|
-
);
|
|
254
|
-
const llmConnId =
|
|
255
|
-
typeof (llm as Record<string, unknown>)?.connectionId === "string"
|
|
256
|
-
? ((llm as Record<string, unknown>).connectionId as string)
|
|
257
|
-
: "";
|
|
258
|
-
const embConnId =
|
|
259
|
-
typeof (embeddings as Record<string, unknown>)?.connectionId === "string"
|
|
260
|
-
? ((embeddings as Record<string, unknown>).connectionId as string)
|
|
261
|
-
: "";
|
|
262
|
-
|
|
263
|
-
if (llmConnId && !connectionIds.has(llmConnId)) {
|
|
264
|
-
errors.push(`assignments.llm.connectionId "${llmConnId}" does not match any connection`);
|
|
265
|
-
}
|
|
266
|
-
if (embConnId && !connectionIds.has(embConnId)) {
|
|
267
|
-
errors.push(`assignments.embeddings.connectionId "${embConnId}" does not match any connection`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function validateSetupInput(input: unknown): { valid: boolean; errors: string[] } {
|
|
273
|
-
const errors: string[] = [];
|
|
274
|
-
|
|
275
|
-
if (typeof input !== "object" || input === null) {
|
|
276
|
-
return { valid: false, errors: ["Input must be a non-null object"] };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const body = input as Record<string, unknown>;
|
|
280
|
-
|
|
281
|
-
// adminToken
|
|
282
|
-
if (typeof body.adminToken !== "string" || !body.adminToken) {
|
|
283
|
-
errors.push("adminToken is required and must be a non-empty string");
|
|
284
|
-
} else if (body.adminToken.length < 8) {
|
|
285
|
-
errors.push("adminToken must be at least 8 characters");
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ownerName and ownerEmail are optional strings
|
|
289
|
-
if (body.ownerName !== undefined && typeof body.ownerName !== "string") {
|
|
290
|
-
errors.push("ownerName must be a string if provided");
|
|
291
|
-
}
|
|
292
|
-
if (body.ownerEmail !== undefined && typeof body.ownerEmail !== "string") {
|
|
293
|
-
errors.push("ownerEmail must be a string if provided");
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// memoryUserId
|
|
297
|
-
if (body.memoryUserId !== undefined && typeof body.memoryUserId !== "string") {
|
|
298
|
-
errors.push("memoryUserId must be a string");
|
|
299
|
-
}
|
|
300
|
-
if (typeof body.memoryUserId === "string" && !/^[A-Za-z0-9_]+$/.test(body.memoryUserId)) {
|
|
301
|
-
errors.push("memoryUserId contains invalid characters (alphanumeric and underscores only)");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// ollamaEnabled
|
|
305
|
-
if (body.ollamaEnabled !== undefined && typeof body.ollamaEnabled !== "boolean") {
|
|
306
|
-
errors.push("ollamaEnabled must be a boolean");
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// voice (optional)
|
|
310
|
-
if (body.voice !== undefined) {
|
|
311
|
-
if (typeof body.voice !== "object" || body.voice === null) {
|
|
312
|
-
errors.push("voice must be an object if provided");
|
|
313
|
-
} else {
|
|
314
|
-
const voice = body.voice as Record<string, unknown>;
|
|
315
|
-
if (voice.tts !== undefined && voice.tts !== null && typeof voice.tts !== "string") {
|
|
316
|
-
errors.push("voice.tts must be a string or null if provided");
|
|
317
|
-
}
|
|
318
|
-
if (voice.stt !== undefined && voice.stt !== null && typeof voice.stt !== "string") {
|
|
319
|
-
errors.push("voice.stt must be a string or null if provided");
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// channels (optional)
|
|
325
|
-
if (body.channels !== undefined) {
|
|
326
|
-
if (!Array.isArray(body.channels)) {
|
|
327
|
-
errors.push("channels must be an array if provided");
|
|
328
|
-
} else {
|
|
329
|
-
for (let i = 0; i < body.channels.length; i++) {
|
|
330
|
-
if (typeof body.channels[i] !== "string") {
|
|
331
|
-
errors.push(`channels[${i}] must be a string`);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// services (optional)
|
|
338
|
-
if (body.services !== undefined) {
|
|
339
|
-
if (typeof body.services !== "object" || body.services === null) {
|
|
340
|
-
errors.push("services must be an object if provided");
|
|
341
|
-
} else {
|
|
342
|
-
const services = body.services as Record<string, unknown>;
|
|
343
|
-
for (const [key, val] of Object.entries(services)) {
|
|
344
|
-
if (typeof val !== "boolean") {
|
|
345
|
-
errors.push(`services.${key} must be a boolean`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// connections
|
|
352
|
-
validateConnectionsArray(body.connections, errors);
|
|
353
|
-
|
|
354
|
-
// assignments
|
|
355
|
-
validateAssignmentsBlock(body.assignments, body.connections, errors);
|
|
356
|
-
|
|
357
|
-
return { valid: errors.length === 0, errors };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
62
|
// ── Secrets Builder ──────────────────────────────────────────────────────
|
|
361
63
|
|
|
362
64
|
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* Returns a Record<string, string> of secrets.env updates that should be
|
|
366
|
-
* written during setup.
|
|
65
|
+
* Map provider id → env var for a custom base URL override.
|
|
66
|
+
* Allows writeCapabilityVars to resolve non-default endpoints.
|
|
367
67
|
*/
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
68
|
+
const PROVIDER_BASE_URL_ENV: Record<string, string> = {
|
|
69
|
+
openai: "OPENAI_BASE_URL",
|
|
70
|
+
anthropic: "ANTHROPIC_BASE_URL",
|
|
71
|
+
groq: "GROQ_BASE_URL",
|
|
72
|
+
mistral: "MISTRAL_BASE_URL",
|
|
73
|
+
together: "TOGETHER_BASE_URL",
|
|
74
|
+
deepseek: "DEEPSEEK_BASE_URL",
|
|
75
|
+
xai: "XAI_BASE_URL",
|
|
76
|
+
google: "GOOGLE_BASE_URL",
|
|
77
|
+
huggingface: "HF_BASE_URL",
|
|
78
|
+
ollama: "OLLAMA_BASE_URL",
|
|
79
|
+
lmstudio: "LMSTUDIO_BASE_URL",
|
|
80
|
+
"model-runner": "MODEL_RUNNER_BASE_URL",
|
|
81
|
+
"openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",
|
|
82
|
+
};
|
|
374
83
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
84
|
+
export function buildSecretsFromSetup(
|
|
85
|
+
connections: SetupConnection[],
|
|
86
|
+
owner?: { name?: string; email?: string },
|
|
87
|
+
): Record<string, string> {
|
|
88
|
+
const updates: Record<string, string> = {};
|
|
89
|
+
const ownerName = (owner?.name?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
90
|
+
const ownerEmail = (owner?.email?.trim() ?? "").replace(/[\r\n\0]/g, "").slice(0, 200);
|
|
378
91
|
if (ownerName) updates.OWNER_NAME = ownerName;
|
|
379
92
|
if (ownerEmail) updates.OWNER_EMAIL = ownerEmail;
|
|
380
93
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// Write API keys
|
|
388
|
-
for (const conn of effectiveConnections) {
|
|
389
|
-
if (!conn.apiKey) continue;
|
|
390
|
-
const envVar = connEnvVarMap.get(conn.id);
|
|
391
|
-
if (envVar) updates[envVar] = conn.apiKey;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// System LLM vars
|
|
395
|
-
const llmConnection = effectiveConnections.find((c) => c.id === input.assignments.llm.connectionId);
|
|
396
|
-
if (llmConnection) {
|
|
397
|
-
updates.SYSTEM_LLM_PROVIDER = llmConnection.provider;
|
|
398
|
-
updates.SYSTEM_LLM_MODEL = input.assignments.llm.model;
|
|
399
|
-
if (llmConnection.baseUrl) {
|
|
400
|
-
updates.SYSTEM_LLM_BASE_URL = llmConnection.baseUrl;
|
|
401
|
-
const normalizedUrl = llmConnection.baseUrl.replace(/\/+$/, "");
|
|
402
|
-
updates.OPENAI_BASE_URL = normalizedUrl.endsWith("/v1") ? normalizedUrl : `${normalizedUrl}/v1`;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Memory user ID
|
|
407
|
-
updates.MEMORY_USER_ID = input.memoryUserId || "default_user";
|
|
408
|
-
|
|
409
|
-
// Voice: TTS/STT env vars based on engine choice
|
|
410
|
-
if (input.voice) {
|
|
411
|
-
// Find an OpenAI-compatible connection for cloud engines.
|
|
412
|
-
// Normalize base URL: strip trailing slashes and /v1 suffix since
|
|
413
|
-
// the voice channel providers append /v1/... themselves.
|
|
414
|
-
const openaiConn = effectiveConnections.find((c) => c.provider === "openai");
|
|
415
|
-
const openaiBaseUrl = (openaiConn?.baseUrl || "https://api.openai.com")
|
|
416
|
-
.replace(/\/+$/, "")
|
|
417
|
-
.replace(/\/v1$/, "");
|
|
418
|
-
const openaiKey = openaiConn?.apiKey || "";
|
|
419
|
-
|
|
420
|
-
const { tts, stt } = input.voice;
|
|
421
|
-
|
|
422
|
-
if (stt === "openai-stt") {
|
|
423
|
-
updates.STT_BASE_URL = openaiBaseUrl;
|
|
424
|
-
updates.STT_API_KEY = openaiKey;
|
|
425
|
-
updates.STT_MODEL = "whisper-1";
|
|
426
|
-
} else if (stt === "whisper-local") {
|
|
427
|
-
updates.STT_BASE_URL = "http://whisper:9000";
|
|
428
|
-
updates.STT_MODEL = "whisper-1";
|
|
94
|
+
for (const cap of connections) {
|
|
95
|
+
// API key: spec value takes precedence, then fall back to environment
|
|
96
|
+
const envVar = PROVIDER_KEY_MAP[cap.provider];
|
|
97
|
+
if (envVar) {
|
|
98
|
+
const key = cap.apiKey || process.env[envVar] || "";
|
|
99
|
+
if (key) updates[envVar] = key;
|
|
429
100
|
}
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
updates
|
|
434
|
-
updates.TTS_API_KEY = openaiKey;
|
|
435
|
-
updates.TTS_MODEL = "tts-1";
|
|
436
|
-
updates.TTS_VOICE = "alloy";
|
|
437
|
-
} else if (tts === "kokoro") {
|
|
438
|
-
updates.TTS_BASE_URL = "http://kokoro:8880";
|
|
439
|
-
updates.TTS_MODEL = "kokoro";
|
|
440
|
-
} else if (tts === "piper") {
|
|
441
|
-
updates.TTS_BASE_URL = "http://piper:5000";
|
|
442
|
-
updates.TTS_MODEL = "piper";
|
|
101
|
+
// Persist user-configured base URL for any provider so writeCapabilityVars can resolve it
|
|
102
|
+
if (cap.baseUrl) {
|
|
103
|
+
const urlEnv = PROVIDER_BASE_URL_ENV[cap.provider];
|
|
104
|
+
if (urlEnv) updates[urlEnv] = cap.baseUrl;
|
|
443
105
|
}
|
|
444
|
-
// browser-tts / skip-tts: no env vars needed (voice channel falls back to browser)
|
|
445
106
|
}
|
|
446
|
-
|
|
447
107
|
return updates;
|
|
448
108
|
}
|
|
449
109
|
|
|
450
|
-
// ── Connection Env Var Map Builder ───────────────────────────────────────
|
|
451
|
-
|
|
452
110
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
111
|
+
* Read auth.json and extract API keys for OAuth-authenticated providers.
|
|
112
|
+
* This fills the gap where OAuth auth writes tokens to auth.json but
|
|
113
|
+
* not to stack.env — the memory service needs them as env vars.
|
|
455
114
|
*/
|
|
456
|
-
export function
|
|
457
|
-
|
|
458
|
-
)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
115
|
+
export function extractAuthJsonKeys(vaultDir: string): Record<string, string> {
|
|
116
|
+
const authJsonPath = `${vaultDir}/stack/auth.json`;
|
|
117
|
+
if (!existsSync(authJsonPath)) return {};
|
|
118
|
+
try {
|
|
119
|
+
const raw = readFileSync(authJsonPath, "utf-8").trim();
|
|
120
|
+
if (!raw || raw === "{}") return {};
|
|
121
|
+
const auth = JSON.parse(raw) as Record<string, unknown>;
|
|
122
|
+
const updates: Record<string, string> = {};
|
|
123
|
+
for (const [provider, entry] of Object.entries(auth)) {
|
|
124
|
+
if (!entry || typeof entry !== "object") continue;
|
|
125
|
+
const record = entry as Record<string, unknown>;
|
|
126
|
+
// OpenCode stores API keys as { token: "..." } or { apiKey: "..." }
|
|
127
|
+
const token = (record.token ?? record.apiKey ?? record.api_key ?? record.key) as string | undefined;
|
|
128
|
+
if (token && typeof token === "string") {
|
|
129
|
+
const envVar = PROVIDER_KEY_MAP[provider];
|
|
130
|
+
if (envVar) updates[envVar] = token;
|
|
131
|
+
}
|
|
471
132
|
}
|
|
472
|
-
|
|
473
|
-
|
|
133
|
+
return updates;
|
|
134
|
+
} catch {
|
|
135
|
+
return {};
|
|
474
136
|
}
|
|
475
|
-
|
|
476
|
-
return connEnvVarMap;
|
|
477
137
|
}
|
|
478
138
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
* 3. Update secrets.env with API keys and system config
|
|
488
|
-
* 4. Build and write memory config via buildMem0Mapping()
|
|
489
|
-
* 5. Write connection profiles
|
|
490
|
-
* 6. Ensure OpenCode configs
|
|
491
|
-
* 7. Apply install via applyInstall()
|
|
492
|
-
*
|
|
493
|
-
* Does NOT include Docker operations (compose up, pull, etc.) — the caller
|
|
494
|
-
* handles those separately after setup completes.
|
|
495
|
-
*/
|
|
496
|
-
export async function performSetup(
|
|
497
|
-
input: SetupInput,
|
|
498
|
-
assetProvider: CoreAssetProvider,
|
|
499
|
-
opts?: { state?: ControlPlaneState }
|
|
500
|
-
): Promise<SetupResult> {
|
|
501
|
-
// ── Validate ─────────────────────────────────────────────────────────
|
|
502
|
-
const validation = validateSetupInput(input);
|
|
503
|
-
if (!validation.valid) {
|
|
504
|
-
return { ok: false, error: validation.errors.join("; ") };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
logger.info("performing setup", {
|
|
508
|
-
connectionCount: input.connections.length,
|
|
509
|
-
ollamaEnabled: input.ollamaEnabled,
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
// ── Resolve state ────────────────────────────────────────────────────
|
|
513
|
-
const state = opts?.state ?? createState(input.adminToken);
|
|
514
|
-
|
|
515
|
-
// ── Resolve effective connections (Ollama in-stack override) ──────────
|
|
516
|
-
const effectiveConnections = resolveOllamaUrls(input.connections, input.ollamaEnabled);
|
|
517
|
-
|
|
518
|
-
// ── Build connection env var map ─────────────────────────────────────
|
|
519
|
-
const connEnvVarMap = buildConnectionEnvVarMap(effectiveConnections);
|
|
520
|
-
|
|
521
|
-
// ── Build secrets.env updates ────────────────────────────────────────
|
|
522
|
-
// Pass already-resolved connections to avoid a second resolveOllamaUrls call
|
|
523
|
-
const updates = buildSecretsFromSetup({ ...input, connections: effectiveConnections });
|
|
524
|
-
|
|
525
|
-
// ── Persist secrets.env ──────────────────────────────────────────────
|
|
526
|
-
try {
|
|
527
|
-
ensureXdgDirs();
|
|
528
|
-
ensureSecrets(state);
|
|
529
|
-
ensureConnectionProfilesStore(state.configDir);
|
|
530
|
-
updateSecretsEnv(state, updates);
|
|
531
|
-
} catch (err) {
|
|
532
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
533
|
-
logger.error("failed to update secrets.env", { error: message });
|
|
534
|
-
return { ok: false, error: `Failed to update secrets.env: ${message}` };
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Update state with new admin token
|
|
538
|
-
state.adminToken = input.adminToken;
|
|
539
|
-
writeSetupTokenFile(state);
|
|
540
|
-
|
|
541
|
-
// ── Build and persist Memory config ──────────────────────────────────
|
|
542
|
-
const llmConnectionId = input.assignments.llm.connectionId;
|
|
543
|
-
const embConnectionId = input.assignments.embeddings.connectionId;
|
|
544
|
-
const llmModel = input.assignments.llm.model;
|
|
545
|
-
const llmSmallModel = input.assignments.llm.smallModel || "";
|
|
546
|
-
const embModel = input.assignments.embeddings.model;
|
|
547
|
-
const embDims = input.assignments.embeddings.embeddingDims || 0;
|
|
548
|
-
|
|
549
|
-
const llmConnection = effectiveConnections.find((c) => c.id === llmConnectionId);
|
|
550
|
-
if (!llmConnection) {
|
|
551
|
-
return { ok: false, error: `LLM connection "${llmConnectionId}" not found in connections list` };
|
|
552
|
-
}
|
|
553
|
-
const embConnection = effectiveConnections.find((c) => c.id === embConnectionId);
|
|
554
|
-
if (!embConnection) {
|
|
555
|
-
return { ok: false, error: `Embeddings connection "${embConnectionId}" not found in connections list` };
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const memoryModel = llmSmallModel || llmModel;
|
|
559
|
-
|
|
560
|
-
const llmEnvVar = connEnvVarMap.get(llmConnection.id);
|
|
561
|
-
if (!llmEnvVar) {
|
|
562
|
-
return { ok: false, error: `No env var mapping found for LLM connection "${llmConnection.id}"` };
|
|
563
|
-
}
|
|
564
|
-
const llmApiKeyEnvRef = llmConnection.apiKey ? `env:${llmEnvVar}` : "not-needed";
|
|
565
|
-
|
|
566
|
-
const embEnvVar = connEnvVarMap.get(embConnection.id);
|
|
567
|
-
if (!embEnvVar) {
|
|
568
|
-
return { ok: false, error: `No env var mapping found for embeddings connection "${embConnection.id}"` };
|
|
569
|
-
}
|
|
570
|
-
const embApiKeyEnvRef = embConnection.apiKey ? `env:${embEnvVar}` : "not-needed";
|
|
571
|
-
|
|
572
|
-
const embLookupKey = `${embConnection.provider}/${embModel}`;
|
|
573
|
-
const resolvedDims = embDims || EMBEDDING_DIMS[embLookupKey] || 1536;
|
|
574
|
-
|
|
575
|
-
const omConfig = buildMem0Mapping({
|
|
576
|
-
llm: {
|
|
577
|
-
provider: llmConnection.provider,
|
|
578
|
-
baseUrl: llmConnection.baseUrl,
|
|
579
|
-
model: memoryModel,
|
|
580
|
-
apiKeyRef: llmApiKeyEnvRef,
|
|
581
|
-
},
|
|
582
|
-
embedder: {
|
|
583
|
-
provider: embConnection.provider,
|
|
584
|
-
baseUrl: embConnection.baseUrl,
|
|
585
|
-
model: embModel || "text-embedding-3-small",
|
|
586
|
-
apiKeyRef: embApiKeyEnvRef,
|
|
587
|
-
},
|
|
588
|
-
embeddingDims: resolvedDims,
|
|
589
|
-
customInstructions: "",
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
writeMemoryConfig(state.dataDir, omConfig);
|
|
593
|
-
|
|
594
|
-
// ── Write connection profiles ────────────────────────────────────────
|
|
595
|
-
const profilesInput = effectiveConnections.map((conn) => {
|
|
596
|
-
const apiKeyEnvVar = connEnvVarMap.get(conn.id);
|
|
597
|
-
return {
|
|
598
|
-
id: conn.id,
|
|
599
|
-
name: conn.name,
|
|
600
|
-
provider: conn.provider,
|
|
601
|
-
baseUrl: conn.baseUrl,
|
|
602
|
-
hasApiKey: Boolean(conn.apiKey) && Boolean(apiKeyEnvVar),
|
|
603
|
-
apiKeyEnvVar: apiKeyEnvVar ?? "",
|
|
604
|
-
};
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
writeConnectionsDocument(state.configDir, {
|
|
608
|
-
profiles: profilesInput,
|
|
609
|
-
assignments: {
|
|
610
|
-
llm: input.assignments.llm,
|
|
611
|
-
embeddings: {
|
|
612
|
-
connectionId: input.assignments.embeddings.connectionId,
|
|
613
|
-
model: input.assignments.embeddings.model,
|
|
614
|
-
embeddingDims: resolvedDims,
|
|
615
|
-
},
|
|
616
|
-
} as CapabilityAssignments,
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// ── Ensure OpenCode configs ──────────────────────────────────────────
|
|
620
|
-
ensureOpenCodeConfig();
|
|
621
|
-
ensureOpenCodeSystemConfig(assetProvider);
|
|
622
|
-
ensureAdminOpenCodeConfig(assetProvider);
|
|
623
|
-
ensureMemoryDir();
|
|
624
|
-
|
|
625
|
-
// ── Write stack spec (openpalm.yaml) ─────────────────────────────────
|
|
626
|
-
const stackSpec: StackSpec = {
|
|
627
|
-
version: 3,
|
|
628
|
-
connections: effectiveConnections.map((c) => ({
|
|
629
|
-
id: c.id,
|
|
630
|
-
name: c.name,
|
|
631
|
-
provider: c.provider,
|
|
632
|
-
baseUrl: c.baseUrl,
|
|
633
|
-
})),
|
|
634
|
-
assignments: {
|
|
635
|
-
llm: input.assignments.llm,
|
|
636
|
-
embeddings: {
|
|
637
|
-
connectionId: input.assignments.embeddings.connectionId,
|
|
638
|
-
model: input.assignments.embeddings.model,
|
|
639
|
-
embeddingDims: resolvedDims,
|
|
640
|
-
},
|
|
641
|
-
},
|
|
642
|
-
ollamaEnabled: input.ollamaEnabled,
|
|
643
|
-
...(input.voice ? { voice: input.voice } : {}),
|
|
644
|
-
...(input.channels ? { channels: input.channels } : {}),
|
|
645
|
-
...(input.services ? { services: input.services } : {}),
|
|
139
|
+
export function buildSystemSecretsFromSetup(
|
|
140
|
+
adminToken: string,
|
|
141
|
+
existingSystemEnv: Record<string, string> = {}
|
|
142
|
+
): Record<string, string> {
|
|
143
|
+
return {
|
|
144
|
+
OP_ADMIN_TOKEN: adminToken,
|
|
145
|
+
OP_ASSISTANT_TOKEN: existingSystemEnv.OP_ASSISTANT_TOKEN || randomBytes(32).toString("hex"),
|
|
146
|
+
OP_MEMORY_TOKEN: existingSystemEnv.OP_MEMORY_TOKEN || randomBytes(32).toString("hex"),
|
|
646
147
|
};
|
|
647
|
-
writeStackSpec(state.configDir, stackSpec);
|
|
648
|
-
|
|
649
|
-
// ── Mark setup complete in DATA_HOME stack.env before staging ────────
|
|
650
|
-
const dataStackEnv = `${state.dataDir}/stack.env`;
|
|
651
|
-
mkdirSync(state.dataDir, { recursive: true });
|
|
652
|
-
const stackBase = existsSync(dataStackEnv) ? readFileSync(dataStackEnv, "utf-8") : "";
|
|
653
|
-
writeFileSync(
|
|
654
|
-
dataStackEnv,
|
|
655
|
-
mergeEnvContent(stackBase, {
|
|
656
|
-
OPENPALM_SETUP_COMPLETE: "true",
|
|
657
|
-
OPENPALM_OLLAMA_ENABLED: input.ollamaEnabled ? "true" : "false",
|
|
658
|
-
OPENPALM_ADMIN_ENABLED: input.services?.admin ? "true" : "false",
|
|
659
|
-
})
|
|
660
|
-
);
|
|
661
|
-
|
|
662
|
-
// ── Apply install (stages artifacts, no Docker) ──────────────────────
|
|
663
|
-
applyInstall(state, assetProvider);
|
|
664
|
-
|
|
665
|
-
logger.info("setup complete", {
|
|
666
|
-
connectionCount: input.connections.length,
|
|
667
|
-
llmProvider: llmConnection.provider,
|
|
668
|
-
llmModel,
|
|
669
|
-
embModel,
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
return { ok: true };
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// ── Provider Detection ───────────────────────────────────────────────────
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Detect available local providers in a setup-friendly format.
|
|
679
|
-
* Wraps detectLocalProviders() from model-runner.ts.
|
|
680
|
-
*/
|
|
681
|
-
export async function detectProviders(): Promise<DetectedProvider[]> {
|
|
682
|
-
const raw = await detectLocalProviders();
|
|
683
|
-
return raw.map((r: LocalProviderDetection) => ({
|
|
684
|
-
provider: r.provider,
|
|
685
|
-
url: r.url,
|
|
686
|
-
available: r.available,
|
|
687
|
-
}));
|
|
688
148
|
}
|
|
689
149
|
|
|
690
150
|
// ── Channel Credential Env Var Mapping ───────────────────────────────────
|
|
691
151
|
|
|
692
|
-
|
|
693
|
-
export const CHANNEL_CREDENTIAL_ENV_MAP: Record<string, Record<string, string>> = {
|
|
152
|
+
const CHANNEL_CREDENTIAL_ENV_MAP: Record<string, Record<string, string>> = {
|
|
694
153
|
discord: {
|
|
695
154
|
botToken: "DISCORD_BOT_TOKEN",
|
|
696
155
|
applicationId: "DISCORD_APPLICATION_ID",
|
|
@@ -709,286 +168,99 @@ export const CHANNEL_CREDENTIAL_ENV_MAP: Record<string, Record<string, string>>
|
|
|
709
168
|
},
|
|
710
169
|
};
|
|
711
170
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
return { valid: false, errors: ["Config must be a non-null object"] };
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
const body = config as Record<string, unknown>;
|
|
726
|
-
|
|
727
|
-
// version
|
|
728
|
-
if (body.version !== 1) {
|
|
729
|
-
errors.push("version must be 1");
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// owner (optional)
|
|
733
|
-
if (body.owner !== undefined) {
|
|
734
|
-
if (typeof body.owner !== "object" || body.owner === null) {
|
|
735
|
-
errors.push("owner must be an object if provided");
|
|
736
|
-
} else {
|
|
737
|
-
const owner = body.owner as Record<string, unknown>;
|
|
738
|
-
if (owner.name !== undefined && typeof owner.name !== "string") {
|
|
739
|
-
errors.push("owner.name must be a string if provided");
|
|
740
|
-
}
|
|
741
|
-
if (owner.email !== undefined && typeof owner.email !== "string") {
|
|
742
|
-
errors.push("owner.email must be a string if provided");
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// security
|
|
748
|
-
if (typeof body.security !== "object" || body.security === null) {
|
|
749
|
-
errors.push("security object is required");
|
|
750
|
-
} else {
|
|
751
|
-
const security = body.security as Record<string, unknown>;
|
|
752
|
-
if (typeof security.adminToken !== "string" || !security.adminToken) {
|
|
753
|
-
errors.push("security.adminToken is required and must be a non-empty string");
|
|
754
|
-
} else if (security.adminToken.length < 8) {
|
|
755
|
-
errors.push("security.adminToken must be at least 8 characters");
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// connections
|
|
760
|
-
validateConnectionsArray(body.connections, errors);
|
|
761
|
-
|
|
762
|
-
// assignments
|
|
763
|
-
validateAssignmentsBlock(body.assignments, body.connections, errors);
|
|
764
|
-
|
|
765
|
-
// channels (optional)
|
|
766
|
-
if (body.channels !== undefined) {
|
|
767
|
-
if (typeof body.channels !== "object" || body.channels === null) {
|
|
768
|
-
errors.push("channels must be an object if provided");
|
|
769
|
-
} else {
|
|
770
|
-
const channels = body.channels as Record<string, unknown>;
|
|
771
|
-
for (const [channelId, value] of Object.entries(channels)) {
|
|
772
|
-
if (typeof value === "boolean") continue;
|
|
773
|
-
if (typeof value !== "object" || value === null) {
|
|
774
|
-
errors.push(`channels.${channelId} must be a boolean or object`);
|
|
775
|
-
continue;
|
|
776
|
-
}
|
|
777
|
-
// Channel-specific credential validation
|
|
778
|
-
if (channelId === "discord") {
|
|
779
|
-
const creds = value as Record<string, unknown>;
|
|
780
|
-
if (creds.enabled !== false && !creds.botToken) {
|
|
781
|
-
errors.push("channels.discord.botToken is required when discord is enabled");
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
if (channelId === "slack") {
|
|
785
|
-
const creds = value as Record<string, unknown>;
|
|
786
|
-
if (creds.enabled !== false) {
|
|
787
|
-
if (!creds.slackBotToken) {
|
|
788
|
-
errors.push("channels.slack.slackBotToken is required when slack is enabled");
|
|
789
|
-
}
|
|
790
|
-
if (!creds.slackAppToken) {
|
|
791
|
-
errors.push("channels.slack.slackAppToken is required when slack is enabled");
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// services (optional)
|
|
800
|
-
if (body.services !== undefined) {
|
|
801
|
-
if (typeof body.services !== "object" || body.services === null) {
|
|
802
|
-
errors.push("services must be an object if provided");
|
|
803
|
-
} else {
|
|
804
|
-
const services = body.services as Record<string, unknown>;
|
|
805
|
-
for (const [key, val] of Object.entries(services)) {
|
|
806
|
-
if (typeof val === "boolean") continue;
|
|
807
|
-
if (typeof val !== "object" || val === null) {
|
|
808
|
-
errors.push(`services.${key} must be a boolean or object`);
|
|
809
|
-
} else if (typeof (val as Record<string, unknown>).enabled !== "boolean") {
|
|
810
|
-
errors.push(`services.${key}.enabled must be a boolean`);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// memory (optional)
|
|
817
|
-
if (body.memory !== undefined) {
|
|
818
|
-
if (typeof body.memory !== "object" || body.memory === null) {
|
|
819
|
-
errors.push("memory must be an object if provided");
|
|
820
|
-
} else {
|
|
821
|
-
const memory = body.memory as Record<string, unknown>;
|
|
822
|
-
if (memory.userId !== undefined && typeof memory.userId !== "string") {
|
|
823
|
-
errors.push("memory.userId must be a string if provided");
|
|
824
|
-
}
|
|
825
|
-
if (typeof memory.userId === "string" && !/^[A-Za-z0-9_]+$/.test(memory.userId)) {
|
|
826
|
-
errors.push("memoryUserId contains invalid characters (alphanumeric and underscores only)");
|
|
827
|
-
}
|
|
171
|
+
function buildChannelCredentialEnvVars(
|
|
172
|
+
channelCredentials: Record<string, Record<string, string>>
|
|
173
|
+
): Record<string, string> {
|
|
174
|
+
const envVars: Record<string, string> = {};
|
|
175
|
+
for (const [channelId, creds] of Object.entries(channelCredentials)) {
|
|
176
|
+
const mapping = CHANNEL_CREDENTIAL_ENV_MAP[channelId];
|
|
177
|
+
if (!mapping) continue;
|
|
178
|
+
for (const [field, envKey] of Object.entries(mapping)) {
|
|
179
|
+
const val = creds[field];
|
|
180
|
+
if (typeof val === "string" && val) envVars[envKey] = val;
|
|
828
181
|
}
|
|
829
182
|
}
|
|
830
|
-
|
|
831
|
-
return { valid: errors.length === 0, errors };
|
|
183
|
+
return envVars;
|
|
832
184
|
}
|
|
833
185
|
|
|
834
|
-
// ──
|
|
186
|
+
// ── Core Setup Orchestration ─────────────────────────────────────────────
|
|
835
187
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
let tts: string | undefined;
|
|
843
|
-
let stt: string | undefined;
|
|
188
|
+
export async function performSetup(
|
|
189
|
+
input: SetupSpec,
|
|
190
|
+
opts?: { state?: ControlPlaneState }
|
|
191
|
+
): Promise<SetupResult> {
|
|
192
|
+
const validation = validateSetupSpec(input);
|
|
193
|
+
if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
|
|
844
194
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
// them, the caller should write the original SetupConfig to
|
|
849
|
-
// openpalm.yaml separately.
|
|
850
|
-
tts = typeof config.assignments.tts === "string"
|
|
851
|
-
? config.assignments.tts
|
|
852
|
-
: config.assignments.tts.engine;
|
|
853
|
-
}
|
|
195
|
+
const { capabilities, security, owner, connections, channelCredentials } = input;
|
|
196
|
+
const state = opts?.state ?? createState(security.adminToken);
|
|
197
|
+
const ollamaEnabled = listEnabledAddonIds(state.homeDir).includes("ollama");
|
|
854
198
|
|
|
855
|
-
|
|
856
|
-
// Same as tts above — connectionId and model are dropped during
|
|
857
|
-
// normalization and not written to the stack spec by performSetup().
|
|
858
|
-
stt = typeof config.assignments.stt === "string"
|
|
859
|
-
? config.assignments.stt
|
|
860
|
-
: config.assignments.stt.engine;
|
|
861
|
-
}
|
|
199
|
+
logger.info("performing setup", { capabilityCount: connections.length, ollamaEnabled });
|
|
862
200
|
|
|
863
|
-
//
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
}
|
|
201
|
+
// Apply Ollama in-stack URL override when addon is enabled
|
|
202
|
+
const effectiveConnections = ollamaEnabled
|
|
203
|
+
? connections.map((c) => c.provider === "ollama" ? { ...c, baseUrl: OLLAMA_INSTACK_URL } : c)
|
|
204
|
+
: connections;
|
|
205
|
+
const updates = buildSecretsFromSetup(effectiveConnections, owner);
|
|
206
|
+
|
|
207
|
+
// Merge OAuth-authenticated provider keys from auth.json
|
|
208
|
+
// (OAuth flows store tokens in auth.json, not in the setup payload)
|
|
209
|
+
const oauthKeys = extractAuthJsonKeys(state.vaultDir);
|
|
210
|
+
for (const [key, value] of Object.entries(oauthKeys)) {
|
|
211
|
+
// Only fill in keys that weren't already provided via API key entry
|
|
212
|
+
if (!updates[key]) updates[key] = value;
|
|
876
213
|
}
|
|
877
214
|
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
215
|
+
// Persist vault env files
|
|
216
|
+
try {
|
|
217
|
+
ensureHomeDirs();
|
|
218
|
+
ensureSecrets(state);
|
|
219
|
+
const existingSystemEnv = readStackEnv(state.vaultDir);
|
|
220
|
+
if (channelCredentials) Object.assign(updates, buildChannelCredentialEnvVars(channelCredentials));
|
|
221
|
+
// Pick up channel credential env vars not already provided in the spec
|
|
222
|
+
for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
|
|
223
|
+
for (const envKey of Object.values(mapping)) {
|
|
224
|
+
if (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
|
|
886
225
|
}
|
|
887
226
|
}
|
|
227
|
+
updateSecretsEnv(state, updates);
|
|
228
|
+
updateSystemSecretsEnv(state, buildSystemSecretsFromSetup(security.adminToken, existingSystemEnv));
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231
|
+
logger.error("failed to update vault env files", { error: message });
|
|
232
|
+
return { ok: false, error: `Failed to update vault env files: ${message}` };
|
|
888
233
|
}
|
|
889
234
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
return {
|
|
894
|
-
adminToken: config.security.adminToken,
|
|
895
|
-
ownerName: config.owner?.name,
|
|
896
|
-
ownerEmail: config.owner?.email,
|
|
897
|
-
memoryUserId: config.memory?.userId || "default_user",
|
|
898
|
-
ollamaEnabled,
|
|
899
|
-
connections: config.connections,
|
|
900
|
-
assignments: {
|
|
901
|
-
llm: config.assignments.llm,
|
|
902
|
-
embeddings: config.assignments.embeddings,
|
|
903
|
-
},
|
|
904
|
-
...(tts !== undefined || stt !== undefined ? { voice: { tts, stt } } : {}),
|
|
905
|
-
...(enabledChannels.length > 0 ? { channels: enabledChannels } : {}),
|
|
906
|
-
...(Object.keys(enabledServices).length > 0 ? { services: enabledServices as SetupInput["services"] } : {}),
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// ── Channel Credential Env Var Builder ───────────────────────────────────
|
|
235
|
+
state.adminToken = security.adminToken;
|
|
236
|
+
state.assistantToken = readStackEnv(state.vaultDir).OP_ASSISTANT_TOKEN ?? state.assistantToken;
|
|
237
|
+
writeSetupTokenFile(state);
|
|
911
238
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
* CHANNEL_CREDENTIAL_ENV_MAP and return a Record<string, string> suitable
|
|
915
|
-
* for writing to secrets.env.
|
|
916
|
-
*
|
|
917
|
-
* Unknown channels (not in CHANNEL_CREDENTIAL_ENV_MAP) are skipped.
|
|
918
|
-
* Boolean values are converted to strings.
|
|
919
|
-
*/
|
|
920
|
-
export function buildChannelCredentialEnvVars(
|
|
921
|
-
channels: Record<string, boolean | ChannelCredentials> | undefined
|
|
922
|
-
): Record<string, string> {
|
|
923
|
-
const envVars: Record<string, string> = {};
|
|
924
|
-
if (!channels) return envVars;
|
|
239
|
+
// Write stack.yml and OP_CAP_* capability vars to stack.env
|
|
240
|
+
writeMemoryAndStackConfigs({ version: 2, capabilities }, state);
|
|
925
241
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
242
|
+
ensureOpenCodeConfig();
|
|
243
|
+
ensureOpenCodeSystemConfig();
|
|
244
|
+
ensureMemoryDir();
|
|
929
245
|
|
|
930
|
-
|
|
931
|
-
|
|
246
|
+
// Mark setup complete in vault/stack/stack.env (where isSetupComplete reads it)
|
|
247
|
+
const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
|
|
248
|
+
const systemBase = existsSync(systemEnvPath) ? readFileSync(systemEnvPath, "utf-8") : "";
|
|
249
|
+
writeFileSync(systemEnvPath, mergeEnvContent(systemBase, { OP_SETUP_COMPLETE: "true" }), { mode: 0o600 });
|
|
932
250
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const fieldValue = creds[field];
|
|
936
|
-
if (fieldValue === undefined || fieldValue === null) continue;
|
|
937
|
-
if (typeof fieldValue === "boolean") {
|
|
938
|
-
envVars[envKey] = String(fieldValue);
|
|
939
|
-
} else if (typeof fieldValue === "string" && fieldValue) {
|
|
940
|
-
envVars[envKey] = fieldValue;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
return envVars;
|
|
251
|
+
logger.info("setup complete", { capabilityCount: connections.length });
|
|
252
|
+
return { ok: true };
|
|
946
253
|
}
|
|
947
254
|
|
|
948
|
-
|
|
255
|
+
/** Write stack.yml and OP_CAP_* capability vars to stack.env from the spec's capabilities. */
|
|
256
|
+
function writeMemoryAndStackConfigs(spec: StackSpec, state: ControlPlaneState): void {
|
|
257
|
+
const { provider: embProvider, model: embModel } = spec.capabilities.embeddings;
|
|
258
|
+
const resolvedDims = spec.capabilities.embeddings.dims || EMBEDDING_DIMS[`${embProvider}/${embModel}`] || 1536;
|
|
949
259
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
* Channel credentials are written BEFORE performSetup() so that when
|
|
957
|
-
* performSetup() stages secrets.env from CONFIG_HOME to STATE_HOME
|
|
958
|
-
* (via applyInstall), the staged copy already contains channel creds.
|
|
959
|
-
*/
|
|
960
|
-
export async function performSetupFromConfig(
|
|
961
|
-
config: SetupConfig,
|
|
962
|
-
assetProvider: CoreAssetProvider,
|
|
963
|
-
opts?: { state?: ControlPlaneState }
|
|
964
|
-
): Promise<SetupResult> {
|
|
965
|
-
// ── Validate ─────────────────────────────────────────────────────────
|
|
966
|
-
const validation = validateSetupConfig(config);
|
|
967
|
-
if (!validation.valid) {
|
|
968
|
-
return { ok: false, error: validation.errors.join("; ") };
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
// ── Write channel credentials to CONFIG_HOME/secrets.env FIRST ─────
|
|
972
|
-
// This must happen before performSetup() which stages secrets.env
|
|
973
|
-
// from CONFIG_HOME to STATE_HOME via applyInstall().
|
|
974
|
-
const input = normalizeToSetupInput(config);
|
|
975
|
-
const state = opts?.state ?? createState(config.security.adminToken);
|
|
976
|
-
const channelEnvVars = buildChannelCredentialEnvVars(config.channels);
|
|
977
|
-
if (Object.keys(channelEnvVars).length > 0) {
|
|
978
|
-
try {
|
|
979
|
-
ensureXdgDirs();
|
|
980
|
-
ensureSecrets(state);
|
|
981
|
-
updateSecretsEnv(state, channelEnvVars);
|
|
982
|
-
} catch (err) {
|
|
983
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
984
|
-
logger.error("failed to write channel credentials to secrets.env", { error: message });
|
|
985
|
-
return { ok: false, error: `Failed to write channel credentials: ${message}` };
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// ── Normalize and delegate to performSetup ─────────────────────────
|
|
990
|
-
// performSetup() writes its own secrets (admin token, API keys, etc.)
|
|
991
|
-
// and then stages the now-complete secrets.env to STATE_HOME.
|
|
992
|
-
const result = await performSetup(input, assetProvider, { ...opts, state });
|
|
993
|
-
return result;
|
|
260
|
+
const specToWrite: StackSpec = {
|
|
261
|
+
...spec,
|
|
262
|
+
capabilities: { ...spec.capabilities, embeddings: { ...spec.capabilities.embeddings, dims: resolvedDims } },
|
|
263
|
+
};
|
|
264
|
+
writeStackSpec(state.configDir, specToWrite);
|
|
265
|
+
writeCapabilityVars(specToWrite, state.vaultDir);
|
|
994
266
|
}
|