@openpalm/lib 0.9.6 → 0.9.8
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/package.json +1 -1
- package/src/control-plane/channels.ts +3 -0
- package/src/control-plane/connection-mapping.ts +2 -2
- package/src/control-plane/core-asset-provider.ts +1 -0
- package/src/control-plane/core-assets.ts +28 -0
- package/src/control-plane/docker.ts +2 -1
- package/src/control-plane/env.test.ts +109 -0
- package/src/control-plane/env.ts +2 -2
- package/src/control-plane/fs-asset-provider.ts +4 -0
- package/src/control-plane/install-edge-cases.test.ts +1214 -0
- package/src/control-plane/lifecycle.ts +11 -2
- package/src/control-plane/model-runner.ts +27 -2
- package/src/control-plane/setup-status.ts +1 -1
- package/src/control-plane/setup.test.ts +720 -1
- package/src/control-plane/setup.ts +597 -115
- package/src/control-plane/stack-spec.ts +64 -0
- package/src/control-plane/staging.ts +29 -6
- package/src/control-plane/types.ts +2 -3
- package/src/index.ts +30 -0
- package/src/provider-constants.ts +13 -2
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge-case tests for the OpenPalm install and setup flow.
|
|
3
|
+
*
|
|
4
|
+
* Each test creates its own temp directory tree mimicking the XDG layout
|
|
5
|
+
* (CONFIG_HOME, DATA_HOME, STATE_HOME), then runs the actual library
|
|
6
|
+
* functions against it. No mocks of code under test.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { parse as yamlParse } from "yaml";
|
|
20
|
+
|
|
21
|
+
import { parseEnvContent, parseEnvFile, mergeEnvContent } from "./env.js";
|
|
22
|
+
import { ensureSecrets, loadSecretsEnvFile } from "./secrets.js";
|
|
23
|
+
import { isSetupComplete } from "./setup-status.js";
|
|
24
|
+
import {
|
|
25
|
+
performSetup,
|
|
26
|
+
buildSecretsFromSetup,
|
|
27
|
+
buildConnectionEnvVarMap,
|
|
28
|
+
} from "./setup.js";
|
|
29
|
+
import type { SetupInput, SetupConnection } from "./setup.js";
|
|
30
|
+
import type { CoreAssetProvider } from "./core-asset-provider.js";
|
|
31
|
+
import type { ControlPlaneState } from "./types.js";
|
|
32
|
+
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
33
|
+
import { readConnectionProfilesDocument } from "./connection-profiles.js";
|
|
34
|
+
|
|
35
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function makeValidInput(overrides?: Partial<SetupInput>): SetupInput {
|
|
38
|
+
return {
|
|
39
|
+
adminToken: "test-admin-token-12345",
|
|
40
|
+
ownerName: "Test User",
|
|
41
|
+
ownerEmail: "test@example.com",
|
|
42
|
+
memoryUserId: "test_user",
|
|
43
|
+
ollamaEnabled: false,
|
|
44
|
+
connections: [
|
|
45
|
+
{
|
|
46
|
+
id: "openai-main",
|
|
47
|
+
name: "OpenAI",
|
|
48
|
+
provider: "openai",
|
|
49
|
+
baseUrl: "https://api.openai.com",
|
|
50
|
+
apiKey: "sk-test-key-123",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
assignments: {
|
|
54
|
+
llm: { connectionId: "openai-main", model: "gpt-4o" },
|
|
55
|
+
embeddings: {
|
|
56
|
+
connectionId: "openai-main",
|
|
57
|
+
model: "text-embedding-3-small",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createStubAssetProvider(): CoreAssetProvider {
|
|
65
|
+
return {
|
|
66
|
+
coreCompose: () => "services:\n caddy:\n image: caddy:latest\n",
|
|
67
|
+
caddyfile: () =>
|
|
68
|
+
":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
|
|
69
|
+
ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
|
|
70
|
+
adminCompose: () => "services:\n admin:\n image: openpalm/admin\n",
|
|
71
|
+
agentsMd: () => "# Agents\n",
|
|
72
|
+
opencodeConfig: () =>
|
|
73
|
+
'{"$schema":"https://opencode.ai/config.json"}\n',
|
|
74
|
+
adminOpencodeConfig: () =>
|
|
75
|
+
'{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
|
|
76
|
+
secretsSchema: () => "ADMIN_TOKEN=string\n",
|
|
77
|
+
stackSchema: () => "OPENPALM_IMAGE_TAG=string\n",
|
|
78
|
+
cleanupLogs: () => "name: cleanup-logs\nschedule: daily\n",
|
|
79
|
+
cleanupData: () => "name: cleanup-data\nschedule: weekly\n",
|
|
80
|
+
validateConfig: () => "name: validate-config\nschedule: hourly\n",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Shared test fixture ──────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
let tempBase: string;
|
|
87
|
+
let configDir: string;
|
|
88
|
+
let dataDir: string;
|
|
89
|
+
let stateDir: string;
|
|
90
|
+
|
|
91
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
92
|
+
|
|
93
|
+
function saveAndSetEnv(): void {
|
|
94
|
+
savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
|
|
95
|
+
savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
|
|
96
|
+
savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
|
|
97
|
+
process.env.OPENPALM_CONFIG_HOME = configDir;
|
|
98
|
+
process.env.OPENPALM_DATA_HOME = dataDir;
|
|
99
|
+
process.env.OPENPALM_STATE_HOME = stateDir;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function restoreEnv(): void {
|
|
103
|
+
process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
|
|
104
|
+
process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
|
|
105
|
+
process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Create a full directory tree matching ensureXdgDirs() output. */
|
|
109
|
+
function createFullDirTree(): void {
|
|
110
|
+
tempBase = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
|
|
111
|
+
configDir = join(tempBase, "config");
|
|
112
|
+
dataDir = join(tempBase, "data");
|
|
113
|
+
stateDir = join(tempBase, "state");
|
|
114
|
+
|
|
115
|
+
for (const dir of [
|
|
116
|
+
configDir,
|
|
117
|
+
join(configDir, "channels"),
|
|
118
|
+
join(configDir, "connections"),
|
|
119
|
+
join(configDir, "assistant"),
|
|
120
|
+
join(configDir, "automations"),
|
|
121
|
+
join(configDir, "stash"),
|
|
122
|
+
dataDir,
|
|
123
|
+
join(dataDir, "admin"),
|
|
124
|
+
join(dataDir, "memory"),
|
|
125
|
+
join(dataDir, "assistant"),
|
|
126
|
+
join(dataDir, "guardian"),
|
|
127
|
+
join(dataDir, "caddy"),
|
|
128
|
+
join(dataDir, "caddy", "data"),
|
|
129
|
+
join(dataDir, "caddy", "config"),
|
|
130
|
+
join(dataDir, "automations"),
|
|
131
|
+
join(dataDir, "opencode"),
|
|
132
|
+
stateDir,
|
|
133
|
+
join(stateDir, "artifacts"),
|
|
134
|
+
join(stateDir, "audit"),
|
|
135
|
+
join(stateDir, "artifacts", "channels"),
|
|
136
|
+
join(stateDir, "automations"),
|
|
137
|
+
join(stateDir, "opencode"),
|
|
138
|
+
]) {
|
|
139
|
+
mkdirSync(dir, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Seed the minimal secrets.env and stack.env needed for most tests. */
|
|
144
|
+
function seedMinimalEnvFiles(): void {
|
|
145
|
+
writeFileSync(
|
|
146
|
+
join(configDir, "secrets.env"),
|
|
147
|
+
[
|
|
148
|
+
"# OpenPalm Secrets",
|
|
149
|
+
"export OPENPALM_ADMIN_TOKEN=",
|
|
150
|
+
"export ADMIN_TOKEN=",
|
|
151
|
+
"export OPENAI_API_KEY=",
|
|
152
|
+
"export OPENAI_BASE_URL=",
|
|
153
|
+
"export ANTHROPIC_API_KEY=",
|
|
154
|
+
"export GROQ_API_KEY=",
|
|
155
|
+
"export MISTRAL_API_KEY=",
|
|
156
|
+
"export GOOGLE_API_KEY=",
|
|
157
|
+
"export MEMORY_USER_ID=default_user",
|
|
158
|
+
"export MEMORY_AUTH_TOKEN=abc123",
|
|
159
|
+
"export OWNER_NAME=",
|
|
160
|
+
"export OWNER_EMAIL=",
|
|
161
|
+
"",
|
|
162
|
+
].join("\n")
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
167
|
+
"OPENPALM_SETUP_COMPLETE=false\n"
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Test Suite ───────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
// =====================================================================
|
|
174
|
+
// FRESH INSTALL (empty directories)
|
|
175
|
+
// =====================================================================
|
|
176
|
+
|
|
177
|
+
describe("Fresh Install", () => {
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
createFullDirTree();
|
|
180
|
+
saveAndSetEnv();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
restoreEnv();
|
|
185
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Scenario 1: ensureSecrets creates secrets.env with all required keys
|
|
189
|
+
it("ensureSecrets creates secrets.env with MEMORY_AUTH_TOKEN when file does not exist", () => {
|
|
190
|
+
const state: ControlPlaneState = {
|
|
191
|
+
adminToken: "",
|
|
192
|
+
setupToken: "",
|
|
193
|
+
stateDir,
|
|
194
|
+
configDir,
|
|
195
|
+
dataDir,
|
|
196
|
+
services: {},
|
|
197
|
+
artifacts: { compose: "", caddyfile: "" },
|
|
198
|
+
artifactMeta: [],
|
|
199
|
+
audit: [],
|
|
200
|
+
channelSecrets: {},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// No secrets.env exists yet
|
|
204
|
+
expect(existsSync(join(configDir, "secrets.env"))).toBe(false);
|
|
205
|
+
|
|
206
|
+
ensureSecrets(state);
|
|
207
|
+
|
|
208
|
+
const content = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
209
|
+
expect(content).toContain("MEMORY_AUTH_TOKEN=");
|
|
210
|
+
// Token should be a non-empty hex string (64 chars for 32 bytes)
|
|
211
|
+
const match = content.match(/MEMORY_AUTH_TOKEN=([a-f0-9]+)/);
|
|
212
|
+
expect(match).not.toBeNull();
|
|
213
|
+
expect(match![1].length).toBe(64);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Scenario 2: isSetupComplete returns false before setup
|
|
217
|
+
it("isSetupComplete returns false when stack.env has OPENPALM_SETUP_COMPLETE=false", () => {
|
|
218
|
+
writeFileSync(
|
|
219
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
220
|
+
"OPENPALM_SETUP_COMPLETE=false\n"
|
|
221
|
+
);
|
|
222
|
+
// Empty secrets.env so fallback check doesn't trigger
|
|
223
|
+
writeFileSync(join(configDir, "secrets.env"), "");
|
|
224
|
+
|
|
225
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Scenario 3: performSetup succeeds from completely empty state
|
|
229
|
+
it("performSetup succeeds from completely empty state", async () => {
|
|
230
|
+
seedMinimalEnvFiles();
|
|
231
|
+
|
|
232
|
+
const result = await performSetup(
|
|
233
|
+
makeValidInput(),
|
|
234
|
+
createStubAssetProvider()
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(result.ok).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Scenario 4: isSetupComplete returns true after performSetup
|
|
241
|
+
it("isSetupComplete returns true after performSetup", async () => {
|
|
242
|
+
seedMinimalEnvFiles();
|
|
243
|
+
|
|
244
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
245
|
+
|
|
246
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// =====================================================================
|
|
251
|
+
// EXISTING INSTALL (pre-populated directories)
|
|
252
|
+
// =====================================================================
|
|
253
|
+
|
|
254
|
+
describe("Existing Install", () => {
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
createFullDirTree();
|
|
257
|
+
seedMinimalEnvFiles();
|
|
258
|
+
saveAndSetEnv();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
afterEach(() => {
|
|
262
|
+
restoreEnv();
|
|
263
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Scenario 5: ensureSecrets does NOT overwrite existing secrets.env
|
|
267
|
+
it("ensureSecrets does not overwrite existing secrets.env", () => {
|
|
268
|
+
const customContent =
|
|
269
|
+
"export OPENPALM_ADMIN_TOKEN=my-custom-token\nexport MEMORY_AUTH_TOKEN=custom-auth-token\n";
|
|
270
|
+
writeFileSync(join(configDir, "secrets.env"), customContent);
|
|
271
|
+
|
|
272
|
+
const state: ControlPlaneState = {
|
|
273
|
+
adminToken: "",
|
|
274
|
+
setupToken: "",
|
|
275
|
+
stateDir,
|
|
276
|
+
configDir,
|
|
277
|
+
dataDir,
|
|
278
|
+
services: {},
|
|
279
|
+
artifacts: { compose: "", caddyfile: "" },
|
|
280
|
+
artifactMeta: [],
|
|
281
|
+
audit: [],
|
|
282
|
+
channelSecrets: {},
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
ensureSecrets(state);
|
|
286
|
+
|
|
287
|
+
const afterContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
288
|
+
expect(afterContent).toBe(customContent);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Scenario 6: performSetup re-run preserves MEMORY_AUTH_TOKEN
|
|
292
|
+
it("performSetup re-run preserves MEMORY_AUTH_TOKEN from first run", async () => {
|
|
293
|
+
// First setup
|
|
294
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
295
|
+
|
|
296
|
+
const secretsAfterFirst = readFileSync(
|
|
297
|
+
join(configDir, "secrets.env"),
|
|
298
|
+
"utf-8"
|
|
299
|
+
);
|
|
300
|
+
const firstMatch = secretsAfterFirst.match(
|
|
301
|
+
/MEMORY_AUTH_TOKEN=([a-f0-9]+)/
|
|
302
|
+
);
|
|
303
|
+
expect(firstMatch).not.toBeNull();
|
|
304
|
+
const firstToken = firstMatch![1];
|
|
305
|
+
|
|
306
|
+
// Second setup (re-run with different API key)
|
|
307
|
+
await performSetup(
|
|
308
|
+
makeValidInput({
|
|
309
|
+
connections: [
|
|
310
|
+
{
|
|
311
|
+
id: "openai-main",
|
|
312
|
+
name: "OpenAI",
|
|
313
|
+
provider: "openai",
|
|
314
|
+
baseUrl: "https://api.openai.com",
|
|
315
|
+
apiKey: "sk-different-key-999",
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
}),
|
|
319
|
+
createStubAssetProvider()
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const secretsAfterSecond = readFileSync(
|
|
323
|
+
join(configDir, "secrets.env"),
|
|
324
|
+
"utf-8"
|
|
325
|
+
);
|
|
326
|
+
const secondMatch = secretsAfterSecond.match(
|
|
327
|
+
/MEMORY_AUTH_TOKEN=([a-f0-9]+)/
|
|
328
|
+
);
|
|
329
|
+
expect(secondMatch).not.toBeNull();
|
|
330
|
+
// MEMORY_AUTH_TOKEN should be preserved (buildSecretsFromSetup does not overwrite it)
|
|
331
|
+
expect(secondMatch![1]).toBe(firstToken);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Scenario 7: stageStackEnv preserves OPENPALM_SETUP_COMPLETE=true from existing stack.env
|
|
335
|
+
it("performSetup marks OPENPALM_SETUP_COMPLETE=true in staged stack.env", async () => {
|
|
336
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
337
|
+
|
|
338
|
+
const stagedStack = readFileSync(
|
|
339
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
340
|
+
"utf-8"
|
|
341
|
+
);
|
|
342
|
+
const parsed = parseEnvContent(stagedStack);
|
|
343
|
+
expect(parsed.OPENPALM_SETUP_COMPLETE).toBe("true");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Scenario 8: Re-setup with different provider preserves existing connections
|
|
347
|
+
it("re-setup with different provider writes new connection profiles", async () => {
|
|
348
|
+
// First setup with OpenAI
|
|
349
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
350
|
+
|
|
351
|
+
const profilesAfterFirst = readConnectionProfilesDocument(configDir);
|
|
352
|
+
expect(profilesAfterFirst.profiles).toHaveLength(1);
|
|
353
|
+
expect(profilesAfterFirst.profiles[0].provider).toBe("openai");
|
|
354
|
+
|
|
355
|
+
// Second setup with Groq
|
|
356
|
+
await performSetup(
|
|
357
|
+
makeValidInput({
|
|
358
|
+
connections: [
|
|
359
|
+
{
|
|
360
|
+
id: "groq-main",
|
|
361
|
+
name: "Groq",
|
|
362
|
+
provider: "groq",
|
|
363
|
+
baseUrl: "https://api.groq.com/openai",
|
|
364
|
+
apiKey: "gsk-test-key-456",
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
assignments: {
|
|
368
|
+
llm: { connectionId: "groq-main", model: "llama3-70b-8192" },
|
|
369
|
+
embeddings: {
|
|
370
|
+
connectionId: "groq-main",
|
|
371
|
+
model: "text-embedding-3-small",
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
createStubAssetProvider()
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const profilesAfterSecond = readConnectionProfilesDocument(configDir);
|
|
379
|
+
// performSetup writes the full document, so second setup replaces profiles
|
|
380
|
+
expect(profilesAfterSecond.profiles).toHaveLength(1);
|
|
381
|
+
expect(profilesAfterSecond.profiles[0].provider).toBe("groq");
|
|
382
|
+
|
|
383
|
+
// But secrets.env should retain both keys
|
|
384
|
+
const secrets = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
385
|
+
expect(secrets).toContain("GROQ_API_KEY");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// =====================================================================
|
|
390
|
+
// BROKEN / CORRUPT STATE
|
|
391
|
+
// =====================================================================
|
|
392
|
+
|
|
393
|
+
describe("Broken/Corrupt State", () => {
|
|
394
|
+
beforeEach(() => {
|
|
395
|
+
createFullDirTree();
|
|
396
|
+
saveAndSetEnv();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
afterEach(() => {
|
|
400
|
+
restoreEnv();
|
|
401
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Scenario 9: secrets.env exists but is empty
|
|
405
|
+
it("ensureSecrets returns early for an empty but existing secrets.env", () => {
|
|
406
|
+
writeFileSync(join(configDir, "secrets.env"), "");
|
|
407
|
+
|
|
408
|
+
const state: ControlPlaneState = {
|
|
409
|
+
adminToken: "",
|
|
410
|
+
setupToken: "",
|
|
411
|
+
stateDir,
|
|
412
|
+
configDir,
|
|
413
|
+
dataDir,
|
|
414
|
+
services: {},
|
|
415
|
+
artifacts: { compose: "", caddyfile: "" },
|
|
416
|
+
artifactMeta: [],
|
|
417
|
+
audit: [],
|
|
418
|
+
channelSecrets: {},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
ensureSecrets(state);
|
|
422
|
+
|
|
423
|
+
// File should still exist and still be empty (ensureSecrets only checks existence)
|
|
424
|
+
const content = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
425
|
+
expect(content).toBe("");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Scenario 10: secrets.env with malformed lines
|
|
429
|
+
it("parseEnvFile handles malformed env lines gracefully", () => {
|
|
430
|
+
const malformedContent = [
|
|
431
|
+
"# Comment line",
|
|
432
|
+
"VALID_KEY=valid_value",
|
|
433
|
+
"no_equals_sign_here",
|
|
434
|
+
"export EXPORTED_KEY=exported_value",
|
|
435
|
+
" WHITESPACE_KEY= whitespace_value ",
|
|
436
|
+
"=starts_with_equals",
|
|
437
|
+
"",
|
|
438
|
+
"ANOTHER_VALID=value",
|
|
439
|
+
" # indented comment",
|
|
440
|
+
].join("\n");
|
|
441
|
+
|
|
442
|
+
writeFileSync(join(configDir, "secrets.env"), malformedContent);
|
|
443
|
+
|
|
444
|
+
const parsed = parseEnvFile(join(configDir, "secrets.env"));
|
|
445
|
+
expect(parsed.VALID_KEY).toBe("valid_value");
|
|
446
|
+
expect(parsed.EXPORTED_KEY).toBe("exported_value");
|
|
447
|
+
expect(parsed.ANOTHER_VALID).toBe("value");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Scenario 11: stack.env missing OPENPALM_SETUP_COMPLETE
|
|
451
|
+
it("isSetupComplete falls back to token check when OPENPALM_SETUP_COMPLETE missing", () => {
|
|
452
|
+
// stack.env without OPENPALM_SETUP_COMPLETE
|
|
453
|
+
writeFileSync(
|
|
454
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
455
|
+
"OPENPALM_IMAGE_TAG=latest\n"
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// secrets.env without any token
|
|
459
|
+
writeFileSync(
|
|
460
|
+
join(configDir, "secrets.env"),
|
|
461
|
+
"export OPENPALM_ADMIN_TOKEN=\nexport ADMIN_TOKEN=\n"
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("isSetupComplete falls back to true when admin token is set but OPENPALM_SETUP_COMPLETE missing", () => {
|
|
468
|
+
writeFileSync(
|
|
469
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
470
|
+
"OPENPALM_IMAGE_TAG=latest\n"
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
writeFileSync(
|
|
474
|
+
join(configDir, "secrets.env"),
|
|
475
|
+
"export OPENPALM_ADMIN_TOKEN=my-real-token\n"
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Scenario 12: API key with special characters round-trips
|
|
482
|
+
it("API key with special characters round-trips through write and parse", () => {
|
|
483
|
+
const specialKeys: Record<string, string> = {
|
|
484
|
+
DOLLAR: "sk-abc$def",
|
|
485
|
+
EQUALS: "sk-abc==def=",
|
|
486
|
+
PLUS_SLASH: "sk-proj-A1b2+xyz/ZZZ==",
|
|
487
|
+
QUOTES: 'sk-say"hello"',
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
for (const [label, value] of Object.entries(specialKeys)) {
|
|
491
|
+
const written = mergeEnvContent("", { [`KEY_${label}`]: value });
|
|
492
|
+
const parsed = parseEnvContent(written);
|
|
493
|
+
expect(parsed[`KEY_${label}`]).toBe(value);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Scenario 13: Corrupt profiles.json
|
|
498
|
+
it("readConnectionProfilesDocument throws on corrupt JSON", () => {
|
|
499
|
+
writeFileSync(
|
|
500
|
+
join(configDir, "connections", "profiles.json"),
|
|
501
|
+
"NOT VALID JSON {{{{"
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
expect(() => readConnectionProfilesDocument(configDir)).toThrow(
|
|
505
|
+
"invalid JSON"
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("readConnectionProfilesDocument throws on valid JSON but wrong structure", () => {
|
|
510
|
+
writeFileSync(
|
|
511
|
+
join(configDir, "connections", "profiles.json"),
|
|
512
|
+
JSON.stringify({ version: 1, profiles: [], assignments: {} })
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
expect(() => readConnectionProfilesDocument(configDir)).toThrow(
|
|
516
|
+
"invalid"
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Scenario 14: CONFIG_HOME exists but STATE_HOME/automations doesn't
|
|
521
|
+
it("performSetup creates missing STATE_HOME subdirectories", async () => {
|
|
522
|
+
// Seed the minimal env files first (needs artifacts dir to exist)
|
|
523
|
+
seedMinimalEnvFiles();
|
|
524
|
+
|
|
525
|
+
// Remove automations dir (performSetup should recreate it)
|
|
526
|
+
rmSync(join(stateDir, "automations"), { recursive: true, force: true });
|
|
527
|
+
|
|
528
|
+
const result = await performSetup(
|
|
529
|
+
makeValidInput(),
|
|
530
|
+
createStubAssetProvider()
|
|
531
|
+
);
|
|
532
|
+
expect(result.ok).toBe(true);
|
|
533
|
+
|
|
534
|
+
// Artifacts should exist
|
|
535
|
+
expect(existsSync(join(stateDir, "artifacts", "docker-compose.yml"))).toBe(
|
|
536
|
+
true
|
|
537
|
+
);
|
|
538
|
+
expect(existsSync(join(stateDir, "artifacts", "Caddyfile"))).toBe(true);
|
|
539
|
+
// Automations dir should be recreated
|
|
540
|
+
expect(existsSync(join(stateDir, "automations"))).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Scenario 15: openpalm.yaml with old version
|
|
544
|
+
it("readStackSpec returns null for version 2 spec", () => {
|
|
545
|
+
writeFileSync(
|
|
546
|
+
join(configDir, STACK_SPEC_FILENAME),
|
|
547
|
+
"version: 2\nservices: []\n"
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const spec = readStackSpec(configDir);
|
|
551
|
+
expect(spec).toBeNull();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// =====================================================================
|
|
556
|
+
// ENVIRONMENT EDGE CASES
|
|
557
|
+
// =====================================================================
|
|
558
|
+
|
|
559
|
+
describe("Environment Edge Cases", () => {
|
|
560
|
+
beforeEach(() => {
|
|
561
|
+
createFullDirTree();
|
|
562
|
+
saveAndSetEnv();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
afterEach(() => {
|
|
566
|
+
restoreEnv();
|
|
567
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Scenario 16: Commented-out ADMIN_TOKEN but OPENPALM_ADMIN_TOKEN set
|
|
571
|
+
it("isSetupComplete detects OPENPALM_ADMIN_TOKEN when ADMIN_TOKEN is commented out", () => {
|
|
572
|
+
writeFileSync(
|
|
573
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
574
|
+
"SOME_OTHER_KEY=value\n"
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
writeFileSync(
|
|
578
|
+
join(configDir, "secrets.env"),
|
|
579
|
+
[
|
|
580
|
+
"export OPENPALM_ADMIN_TOKEN=real-token-here",
|
|
581
|
+
"# export ADMIN_TOKEN=",
|
|
582
|
+
"",
|
|
583
|
+
].join("\n")
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Scenario 17: export prefix on env vars
|
|
590
|
+
it("parseEnvContent strips export prefix correctly", () => {
|
|
591
|
+
const content =
|
|
592
|
+
"export FOO=bar\nexport BAZ=qux\nNO_EXPORT=plain\n";
|
|
593
|
+
const parsed = parseEnvContent(content);
|
|
594
|
+
|
|
595
|
+
expect(parsed.FOO).toBe("bar");
|
|
596
|
+
expect(parsed.BAZ).toBe("qux");
|
|
597
|
+
expect(parsed.NO_EXPORT).toBe("plain");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Scenario 18: Multiple = in value (base64 keys)
|
|
601
|
+
it("parseEnvContent preserves multiple = in value (base64)", () => {
|
|
602
|
+
const content = "API_KEY=sk-abc==def=ghi\n";
|
|
603
|
+
const parsed = parseEnvContent(content);
|
|
604
|
+
expect(parsed.API_KEY).toBe("sk-abc==def=ghi");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("mergeEnvContent round-trips base64 values with trailing ==", () => {
|
|
608
|
+
const value = "dGVzdA==";
|
|
609
|
+
const written = mergeEnvContent("", { TOKEN: value });
|
|
610
|
+
const parsed = parseEnvContent(written);
|
|
611
|
+
expect(parsed.TOKEN).toBe(value);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Scenario 19: Env value containing $HOME or ${VAR}
|
|
615
|
+
it("dollar signs in env values are preserved through round-trip", () => {
|
|
616
|
+
const testCases = ["$HOME/path", "${VAR}", "price$100", "a$b$c"];
|
|
617
|
+
|
|
618
|
+
for (const value of testCases) {
|
|
619
|
+
const written = mergeEnvContent("", { KEY: value });
|
|
620
|
+
const parsed = parseEnvContent(written);
|
|
621
|
+
expect(parsed.KEY).toBe(value);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// =====================================================================
|
|
627
|
+
// SETUP INPUT VARIATIONS
|
|
628
|
+
// =====================================================================
|
|
629
|
+
|
|
630
|
+
describe("Setup Input Variations", () => {
|
|
631
|
+
beforeEach(() => {
|
|
632
|
+
createFullDirTree();
|
|
633
|
+
seedMinimalEnvFiles();
|
|
634
|
+
saveAndSetEnv();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
afterEach(() => {
|
|
638
|
+
restoreEnv();
|
|
639
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// Scenario 20: Ollama in-stack setup
|
|
643
|
+
it("Ollama in-stack setup overrides localhost URL to docker-internal", async () => {
|
|
644
|
+
const input = makeValidInput({
|
|
645
|
+
ollamaEnabled: true,
|
|
646
|
+
connections: [
|
|
647
|
+
{
|
|
648
|
+
id: "ollama-local",
|
|
649
|
+
name: "Ollama",
|
|
650
|
+
provider: "ollama",
|
|
651
|
+
baseUrl: "http://localhost:11434",
|
|
652
|
+
apiKey: "",
|
|
653
|
+
},
|
|
654
|
+
],
|
|
655
|
+
assignments: {
|
|
656
|
+
llm: { connectionId: "ollama-local", model: "llama3.2" },
|
|
657
|
+
embeddings: {
|
|
658
|
+
connectionId: "ollama-local",
|
|
659
|
+
model: "nomic-embed-text",
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const result = await performSetup(input, createStubAssetProvider());
|
|
665
|
+
expect(result.ok).toBe(true);
|
|
666
|
+
|
|
667
|
+
// Connection profiles should use the in-stack URL
|
|
668
|
+
const doc = readConnectionProfilesDocument(configDir);
|
|
669
|
+
expect(doc.profiles[0].baseUrl).toBe("http://ollama:11434");
|
|
670
|
+
|
|
671
|
+
// secrets.env should have in-stack URL
|
|
672
|
+
const secrets = parseEnvFile(join(configDir, "secrets.env"));
|
|
673
|
+
expect(secrets.SYSTEM_LLM_BASE_URL).toBe("http://ollama:11434");
|
|
674
|
+
expect(secrets.OPENAI_BASE_URL).toBe("http://ollama:11434/v1");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// Scenario 21: Multiple providers each get own env var key
|
|
678
|
+
it("multiple providers each get their own env var key (no collision)", () => {
|
|
679
|
+
const connections: SetupConnection[] = [
|
|
680
|
+
{
|
|
681
|
+
id: "openai-1",
|
|
682
|
+
name: "OpenAI",
|
|
683
|
+
provider: "openai",
|
|
684
|
+
baseUrl: "",
|
|
685
|
+
apiKey: "sk-openai",
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
id: "groq-1",
|
|
689
|
+
name: "Groq",
|
|
690
|
+
provider: "groq",
|
|
691
|
+
baseUrl: "",
|
|
692
|
+
apiKey: "gsk-groq",
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
id: "anthropic-1",
|
|
696
|
+
name: "Anthropic",
|
|
697
|
+
provider: "anthropic",
|
|
698
|
+
baseUrl: "",
|
|
699
|
+
apiKey: "sk-ant-api03",
|
|
700
|
+
},
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
const map = buildConnectionEnvVarMap(connections);
|
|
704
|
+
expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
|
|
705
|
+
expect(map.get("groq-1")).toBe("GROQ_API_KEY");
|
|
706
|
+
expect(map.get("anthropic-1")).toBe("ANTHROPIC_API_KEY");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// Scenario 22: Provider URL already ending in /v1
|
|
710
|
+
it("provider URL already ending in /v1 does not get double /v1/v1", () => {
|
|
711
|
+
const secrets = buildSecretsFromSetup(
|
|
712
|
+
makeValidInput({
|
|
713
|
+
connections: [
|
|
714
|
+
{
|
|
715
|
+
id: "openai-compat",
|
|
716
|
+
name: "OpenAI Compatible",
|
|
717
|
+
provider: "openai",
|
|
718
|
+
baseUrl: "https://example.com/v1",
|
|
719
|
+
apiKey: "sk-test",
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
assignments: {
|
|
723
|
+
llm: { connectionId: "openai-compat", model: "gpt-4o" },
|
|
724
|
+
embeddings: {
|
|
725
|
+
connectionId: "openai-compat",
|
|
726
|
+
model: "text-embedding-3-small",
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
})
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
expect(secrets.OPENAI_BASE_URL).toBe("https://example.com/v1");
|
|
733
|
+
expect(secrets.OPENAI_BASE_URL).not.toContain("/v1/v1");
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("provider URL without /v1 gets /v1 appended to OPENAI_BASE_URL", () => {
|
|
737
|
+
const secrets = buildSecretsFromSetup(
|
|
738
|
+
makeValidInput({
|
|
739
|
+
connections: [
|
|
740
|
+
{
|
|
741
|
+
id: "openai-main",
|
|
742
|
+
name: "OpenAI",
|
|
743
|
+
provider: "openai",
|
|
744
|
+
baseUrl: "https://api.openai.com",
|
|
745
|
+
apiKey: "sk-test",
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
assignments: {
|
|
749
|
+
llm: { connectionId: "openai-main", model: "gpt-4o" },
|
|
750
|
+
embeddings: {
|
|
751
|
+
connectionId: "openai-main",
|
|
752
|
+
model: "text-embedding-3-small",
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
})
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
expect(secrets.OPENAI_BASE_URL).toBe("https://api.openai.com/v1");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("provider URL with trailing slash normalizes correctly", () => {
|
|
762
|
+
const secrets = buildSecretsFromSetup(
|
|
763
|
+
makeValidInput({
|
|
764
|
+
connections: [
|
|
765
|
+
{
|
|
766
|
+
id: "openai-main",
|
|
767
|
+
name: "OpenAI",
|
|
768
|
+
provider: "openai",
|
|
769
|
+
baseUrl: "https://api.openai.com/",
|
|
770
|
+
apiKey: "sk-test",
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
assignments: {
|
|
774
|
+
llm: { connectionId: "openai-main", model: "gpt-4o" },
|
|
775
|
+
embeddings: {
|
|
776
|
+
connectionId: "openai-main",
|
|
777
|
+
model: "text-embedding-3-small",
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
expect(secrets.OPENAI_BASE_URL).toBe("https://api.openai.com/v1");
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// =====================================================================
|
|
788
|
+
// COMPREHENSIVE performSetup END-TO-END
|
|
789
|
+
// =====================================================================
|
|
790
|
+
|
|
791
|
+
describe("performSetup end-to-end artifacts", () => {
|
|
792
|
+
beforeEach(() => {
|
|
793
|
+
createFullDirTree();
|
|
794
|
+
seedMinimalEnvFiles();
|
|
795
|
+
saveAndSetEnv();
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
afterEach(() => {
|
|
799
|
+
restoreEnv();
|
|
800
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("writes openpalm.yaml with version 3", async () => {
|
|
804
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
805
|
+
|
|
806
|
+
const spec = readStackSpec(configDir);
|
|
807
|
+
expect(spec).not.toBeNull();
|
|
808
|
+
expect(spec!.version).toBe(3);
|
|
809
|
+
expect(spec!.connections).toHaveLength(1);
|
|
810
|
+
expect(spec!.assignments.llm.model).toBe("gpt-4o");
|
|
811
|
+
expect(spec!.ollamaEnabled).toBe(false);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("writes memory config with correct embedding dims from lookup", async () => {
|
|
815
|
+
const input = makeValidInput({
|
|
816
|
+
connections: [
|
|
817
|
+
{
|
|
818
|
+
id: "ollama-1",
|
|
819
|
+
name: "Ollama",
|
|
820
|
+
provider: "ollama",
|
|
821
|
+
baseUrl: "http://localhost:11434",
|
|
822
|
+
apiKey: "",
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
assignments: {
|
|
826
|
+
llm: { connectionId: "ollama-1", model: "llama3.2" },
|
|
827
|
+
embeddings: {
|
|
828
|
+
connectionId: "ollama-1",
|
|
829
|
+
model: "nomic-embed-text",
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
await performSetup(input, createStubAssetProvider());
|
|
835
|
+
|
|
836
|
+
const memConfig = JSON.parse(
|
|
837
|
+
readFileSync(join(dataDir, "memory", "default_config.json"), "utf-8")
|
|
838
|
+
);
|
|
839
|
+
// nomic-embed-text is 768 dims per EMBEDDING_DIMS constant
|
|
840
|
+
expect(memConfig.mem0.vector_store.config.embedding_model_dims).toBe(768);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("writes docker-compose.yml and Caddyfile to STATE_HOME/artifacts", async () => {
|
|
844
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
845
|
+
|
|
846
|
+
expect(
|
|
847
|
+
existsSync(join(stateDir, "artifacts", "docker-compose.yml"))
|
|
848
|
+
).toBe(true);
|
|
849
|
+
expect(existsSync(join(stateDir, "artifacts", "Caddyfile"))).toBe(true);
|
|
850
|
+
expect(existsSync(join(stateDir, "artifacts", "manifest.json"))).toBe(
|
|
851
|
+
true
|
|
852
|
+
);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("writes secrets.env with correct admin token to both OPENPALM_ADMIN_TOKEN and ADMIN_TOKEN", async () => {
|
|
856
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
857
|
+
|
|
858
|
+
const secrets = parseEnvFile(join(configDir, "secrets.env"));
|
|
859
|
+
expect(secrets.OPENPALM_ADMIN_TOKEN).toBe("test-admin-token-12345");
|
|
860
|
+
expect(secrets.ADMIN_TOKEN).toBe("test-admin-token-12345");
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("creates connection profiles document with correct assignments", async () => {
|
|
864
|
+
await performSetup(makeValidInput(), createStubAssetProvider());
|
|
865
|
+
|
|
866
|
+
const doc = readConnectionProfilesDocument(configDir);
|
|
867
|
+
expect(doc.version).toBe(1);
|
|
868
|
+
expect(doc.profiles).toHaveLength(1);
|
|
869
|
+
expect(doc.profiles[0].id).toBe("openai-main");
|
|
870
|
+
expect(doc.profiles[0].provider).toBe("openai");
|
|
871
|
+
expect(doc.assignments.llm.model).toBe("gpt-4o");
|
|
872
|
+
expect(doc.assignments.embeddings.model).toBe("text-embedding-3-small");
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// =====================================================================
|
|
877
|
+
// mergeEnvContent EDGE CASES
|
|
878
|
+
// =====================================================================
|
|
879
|
+
|
|
880
|
+
describe("mergeEnvContent edge cases", () => {
|
|
881
|
+
it("preserves comments and blank lines when updating existing key", () => {
|
|
882
|
+
const original = [
|
|
883
|
+
"# My header",
|
|
884
|
+
"",
|
|
885
|
+
"export FOO=old",
|
|
886
|
+
"",
|
|
887
|
+
"# Footer comment",
|
|
888
|
+
].join("\n");
|
|
889
|
+
|
|
890
|
+
const result = mergeEnvContent(original, { FOO: "new" });
|
|
891
|
+
expect(result).toContain("# My header");
|
|
892
|
+
expect(result).toContain("# Footer comment");
|
|
893
|
+
|
|
894
|
+
const parsed = parseEnvContent(result);
|
|
895
|
+
expect(parsed.FOO).toBe("new");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("appends new keys to the end when they do not exist", () => {
|
|
899
|
+
const original = "EXISTING=value\n";
|
|
900
|
+
const result = mergeEnvContent(original, { NEW_KEY: "new_value" });
|
|
901
|
+
const parsed = parseEnvContent(result);
|
|
902
|
+
expect(parsed.EXISTING).toBe("value");
|
|
903
|
+
expect(parsed.NEW_KEY).toBe("new_value");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("uncomment option replaces commented-out keys", () => {
|
|
907
|
+
const original = "# export ADMIN_TOKEN=old_value\n";
|
|
908
|
+
const result = mergeEnvContent(
|
|
909
|
+
original,
|
|
910
|
+
{ ADMIN_TOKEN: "new_value" },
|
|
911
|
+
{ uncomment: true }
|
|
912
|
+
);
|
|
913
|
+
const parsed = parseEnvContent(result);
|
|
914
|
+
expect(parsed.ADMIN_TOKEN).toBe("new_value");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("handles empty content gracefully", () => {
|
|
918
|
+
const result = mergeEnvContent("", { KEY: "value" });
|
|
919
|
+
const parsed = parseEnvContent(result);
|
|
920
|
+
expect(parsed.KEY).toBe("value");
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("handles content with only comments", () => {
|
|
924
|
+
const original = "# comment\n# another comment\n";
|
|
925
|
+
const result = mergeEnvContent(original, { KEY: "value" });
|
|
926
|
+
const parsed = parseEnvContent(result);
|
|
927
|
+
expect(parsed.KEY).toBe("value");
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// =====================================================================
|
|
932
|
+
// parseEnvFile / parseEnvContent EDGE CASES
|
|
933
|
+
// =====================================================================
|
|
934
|
+
|
|
935
|
+
describe("parseEnvFile edge cases", () => {
|
|
936
|
+
beforeEach(() => {
|
|
937
|
+
createFullDirTree();
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
afterEach(() => {
|
|
941
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("returns empty object for nonexistent file", () => {
|
|
945
|
+
const result = parseEnvFile(join(configDir, "nonexistent.env"));
|
|
946
|
+
expect(result).toEqual({});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("returns empty object for empty file", () => {
|
|
950
|
+
writeFileSync(join(configDir, "empty.env"), "");
|
|
951
|
+
const result = parseEnvFile(join(configDir, "empty.env"));
|
|
952
|
+
expect(result).toEqual({});
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it("handles single-quoted values", () => {
|
|
956
|
+
writeFileSync(
|
|
957
|
+
join(configDir, "quoted.env"),
|
|
958
|
+
"KEY='value with spaces'\n"
|
|
959
|
+
);
|
|
960
|
+
const result = parseEnvFile(join(configDir, "quoted.env"));
|
|
961
|
+
expect(result.KEY).toBe("value with spaces");
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("handles double-quoted values", () => {
|
|
965
|
+
writeFileSync(
|
|
966
|
+
join(configDir, "quoted.env"),
|
|
967
|
+
'KEY="value with spaces"\n'
|
|
968
|
+
);
|
|
969
|
+
const result = parseEnvFile(join(configDir, "quoted.env"));
|
|
970
|
+
expect(result.KEY).toBe("value with spaces");
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it("handles values with inline comments when unquoted", () => {
|
|
974
|
+
// dotenv spec: unquoted values with # are treated as comments
|
|
975
|
+
writeFileSync(
|
|
976
|
+
join(configDir, "comment.env"),
|
|
977
|
+
"KEY=value # this is a comment\n"
|
|
978
|
+
);
|
|
979
|
+
const result = parseEnvFile(join(configDir, "comment.env"));
|
|
980
|
+
// dotenv library trims at the # for unquoted values
|
|
981
|
+
expect(result.KEY).toBe("value");
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// =====================================================================
|
|
986
|
+
// loadSecretsEnvFile EDGE CASES
|
|
987
|
+
// =====================================================================
|
|
988
|
+
|
|
989
|
+
describe("loadSecretsEnvFile edge cases", () => {
|
|
990
|
+
beforeEach(() => {
|
|
991
|
+
createFullDirTree();
|
|
992
|
+
saveAndSetEnv();
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
afterEach(() => {
|
|
996
|
+
restoreEnv();
|
|
997
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("returns empty object when secrets.env does not exist", () => {
|
|
1001
|
+
const result = loadSecretsEnvFile(configDir);
|
|
1002
|
+
expect(result).toEqual({});
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("filters out keys not matching uppercase alphanumeric pattern", () => {
|
|
1006
|
+
writeFileSync(
|
|
1007
|
+
join(configDir, "secrets.env"),
|
|
1008
|
+
[
|
|
1009
|
+
"VALID_KEY=valid",
|
|
1010
|
+
"another_key=lowercase", // lowercase keys are filtered out
|
|
1011
|
+
"ALSO_VALID=yes",
|
|
1012
|
+
"123_STARTS_NUM=num", // starts with number but matches pattern
|
|
1013
|
+
"",
|
|
1014
|
+
].join("\n")
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
const result = loadSecretsEnvFile(configDir);
|
|
1018
|
+
expect(result.VALID_KEY).toBe("valid");
|
|
1019
|
+
expect(result.ALSO_VALID).toBe("yes");
|
|
1020
|
+
// The regex /^[A-Z0-9_]+$/ does match 123_STARTS_NUM
|
|
1021
|
+
expect(result["123_STARTS_NUM"]).toBe("num");
|
|
1022
|
+
// Lowercase key does not match the filter
|
|
1023
|
+
expect(result.another_key).toBeUndefined();
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
// =====================================================================
|
|
1028
|
+
// isSetupComplete EDGE CASES
|
|
1029
|
+
// =====================================================================
|
|
1030
|
+
|
|
1031
|
+
describe("isSetupComplete edge cases", () => {
|
|
1032
|
+
beforeEach(() => {
|
|
1033
|
+
createFullDirTree();
|
|
1034
|
+
saveAndSetEnv();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
afterEach(() => {
|
|
1038
|
+
restoreEnv();
|
|
1039
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("returns false when stack.env does not exist and no admin token", () => {
|
|
1043
|
+
// No stack.env and no secrets.env
|
|
1044
|
+
rmSync(join(stateDir, "artifacts", "stack.env"), { force: true });
|
|
1045
|
+
|
|
1046
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(false);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("returns true for OPENPALM_SETUP_COMPLETE=TRUE (case insensitive)", () => {
|
|
1050
|
+
writeFileSync(
|
|
1051
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
1052
|
+
"OPENPALM_SETUP_COMPLETE=TRUE\n"
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(true);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("returns true for OPENPALM_SETUP_COMPLETE=True (mixed case)", () => {
|
|
1059
|
+
writeFileSync(
|
|
1060
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
1061
|
+
"OPENPALM_SETUP_COMPLETE=True\n"
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(true);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it("returns false for OPENPALM_SETUP_COMPLETE=false", () => {
|
|
1068
|
+
writeFileSync(
|
|
1069
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
1070
|
+
"OPENPALM_SETUP_COMPLETE=false\n"
|
|
1071
|
+
);
|
|
1072
|
+
writeFileSync(join(configDir, "secrets.env"), "");
|
|
1073
|
+
|
|
1074
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(false);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("falls back to ADMIN_TOKEN presence when OPENPALM_SETUP_COMPLETE not in stack.env", () => {
|
|
1078
|
+
writeFileSync(
|
|
1079
|
+
join(stateDir, "artifacts", "stack.env"),
|
|
1080
|
+
"OPENPALM_IMAGE_TAG=latest\n"
|
|
1081
|
+
);
|
|
1082
|
+
writeFileSync(
|
|
1083
|
+
join(configDir, "secrets.env"),
|
|
1084
|
+
"export ADMIN_TOKEN=my-admin-token\n"
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
expect(isSetupComplete(stateDir, configDir)).toBe(true);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
// =====================================================================
|
|
1092
|
+
// buildSecretsFromSetup EDGE CASES
|
|
1093
|
+
// =====================================================================
|
|
1094
|
+
|
|
1095
|
+
describe("buildSecretsFromSetup edge cases", () => {
|
|
1096
|
+
it("sanitizes owner name with control characters", () => {
|
|
1097
|
+
const input = makeValidInput({ ownerName: "Test\nUser\r\0" });
|
|
1098
|
+
const secrets = buildSecretsFromSetup(input);
|
|
1099
|
+
expect(secrets.OWNER_NAME).toBe("TestUser");
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it("omits empty owner name and email", () => {
|
|
1103
|
+
const input = makeValidInput({ ownerName: "", ownerEmail: "" });
|
|
1104
|
+
const secrets = buildSecretsFromSetup(input);
|
|
1105
|
+
expect(secrets.OWNER_NAME).toBeUndefined();
|
|
1106
|
+
expect(secrets.OWNER_EMAIL).toBeUndefined();
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("defaults memoryUserId to default_user when empty", () => {
|
|
1110
|
+
const input = makeValidInput({ memoryUserId: "" });
|
|
1111
|
+
const secrets = buildSecretsFromSetup(input);
|
|
1112
|
+
expect(secrets.MEMORY_USER_ID).toBe("default_user");
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("sets SYSTEM_LLM_PROVIDER correctly for each provider", () => {
|
|
1116
|
+
for (const provider of ["openai", "groq", "anthropic"] as const) {
|
|
1117
|
+
const envKey =
|
|
1118
|
+
provider === "openai"
|
|
1119
|
+
? "OPENAI_API_KEY"
|
|
1120
|
+
: provider === "groq"
|
|
1121
|
+
? "GROQ_API_KEY"
|
|
1122
|
+
: "ANTHROPIC_API_KEY";
|
|
1123
|
+
|
|
1124
|
+
const input = makeValidInput({
|
|
1125
|
+
connections: [
|
|
1126
|
+
{
|
|
1127
|
+
id: `${provider}-1`,
|
|
1128
|
+
name: provider,
|
|
1129
|
+
provider,
|
|
1130
|
+
baseUrl: "https://api.example.com",
|
|
1131
|
+
apiKey: "sk-test",
|
|
1132
|
+
},
|
|
1133
|
+
],
|
|
1134
|
+
assignments: {
|
|
1135
|
+
llm: { connectionId: `${provider}-1`, model: "test-model" },
|
|
1136
|
+
embeddings: {
|
|
1137
|
+
connectionId: `${provider}-1`,
|
|
1138
|
+
model: "embed-model",
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
});
|
|
1142
|
+
const secrets = buildSecretsFromSetup(input);
|
|
1143
|
+
expect(secrets.SYSTEM_LLM_PROVIDER).toBe(provider);
|
|
1144
|
+
expect(secrets[envKey]).toBe("sk-test");
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// =====================================================================
|
|
1150
|
+
// buildConnectionEnvVarMap EDGE CASES
|
|
1151
|
+
// =====================================================================
|
|
1152
|
+
|
|
1153
|
+
describe("buildConnectionEnvVarMap edge cases", () => {
|
|
1154
|
+
it("handles a single Ollama connection (fallback to OPENAI_API_KEY)", () => {
|
|
1155
|
+
const connections: SetupConnection[] = [
|
|
1156
|
+
{
|
|
1157
|
+
id: "ollama-1",
|
|
1158
|
+
name: "Ollama",
|
|
1159
|
+
provider: "ollama",
|
|
1160
|
+
baseUrl: "http://localhost:11434",
|
|
1161
|
+
apiKey: "",
|
|
1162
|
+
},
|
|
1163
|
+
];
|
|
1164
|
+
const map = buildConnectionEnvVarMap(connections);
|
|
1165
|
+
expect(map.get("ollama-1")).toBe("OPENAI_API_KEY");
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it("skips connections with unsafe env var keys (hyphen creates invalid key)", () => {
|
|
1169
|
+
const connections: SetupConnection[] = [
|
|
1170
|
+
{
|
|
1171
|
+
id: "openai-1",
|
|
1172
|
+
name: "OpenAI",
|
|
1173
|
+
provider: "openai",
|
|
1174
|
+
baseUrl: "",
|
|
1175
|
+
apiKey: "sk-a",
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
id: "openai-2",
|
|
1179
|
+
name: "OpenAI 2",
|
|
1180
|
+
provider: "openai",
|
|
1181
|
+
baseUrl: "",
|
|
1182
|
+
apiKey: "sk-b",
|
|
1183
|
+
},
|
|
1184
|
+
];
|
|
1185
|
+
const map = buildConnectionEnvVarMap(connections);
|
|
1186
|
+
// First gets canonical key
|
|
1187
|
+
expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
|
|
1188
|
+
// Second would be OPENAI_API_KEY_OPENAI-2, which has a hyphen -> skipped
|
|
1189
|
+
expect(map.has("openai-2")).toBe(false);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("namespaces duplicate provider env vars with underscore IDs", () => {
|
|
1193
|
+
const connections: SetupConnection[] = [
|
|
1194
|
+
{
|
|
1195
|
+
id: "openai_1",
|
|
1196
|
+
name: "OpenAI 1",
|
|
1197
|
+
provider: "openai",
|
|
1198
|
+
baseUrl: "",
|
|
1199
|
+
apiKey: "sk-a",
|
|
1200
|
+
},
|
|
1201
|
+
{
|
|
1202
|
+
id: "openai_2",
|
|
1203
|
+
name: "OpenAI 2",
|
|
1204
|
+
provider: "openai",
|
|
1205
|
+
baseUrl: "",
|
|
1206
|
+
apiKey: "sk-b",
|
|
1207
|
+
},
|
|
1208
|
+
];
|
|
1209
|
+
const map = buildConnectionEnvVarMap(connections);
|
|
1210
|
+
expect(map.get("openai_1")).toBe("OPENAI_API_KEY");
|
|
1211
|
+
// openai_2 -> OPENAI_API_KEY_OPENAI_2 which is a safe key
|
|
1212
|
+
expect(map.get("openai_2")).toBe("OPENAI_API_KEY_OPENAI_2");
|
|
1213
|
+
});
|
|
1214
|
+
});
|