@openpalm/lib 0.9.5 → 0.9.7

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.
@@ -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
+ });