@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.
Files changed (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +158 -886
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Edge-case tests for the OpenPalm install and setup flow.
3
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.
4
+ * Each test creates its own temp directory tree mimicking the single
5
+ * ~/.openpalm/ root layout (config, vault, data, logs), then runs the
6
+ * actual library functions against it. No mocks of code under test.
7
7
  */
8
8
  import { describe, expect, it, beforeEach, afterEach } from "bun:test";
9
9
  import {
@@ -16,31 +16,37 @@ import {
16
16
  } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
18
  import { join } from "node:path";
19
- import { parse as yamlParse } from "yaml";
20
-
21
19
  import { parseEnvContent, parseEnvFile, mergeEnvContent } from "./env.js";
22
- import { ensureSecrets, loadSecretsEnvFile } from "./secrets.js";
20
+ import { ensureSecrets, readStackEnv } from "./secrets.js";
23
21
  import { isSetupComplete } from "./setup-status.js";
24
22
  import {
25
23
  performSetup,
26
24
  buildSecretsFromSetup,
27
- buildConnectionEnvVarMap,
25
+ buildSystemSecretsFromSetup,
28
26
  } from "./setup.js";
29
- import type { SetupInput, SetupConnection } from "./setup.js";
30
- import type { CoreAssetProvider } from "./core-asset-provider.js";
27
+ import type { SetupSpec, SetupConnection } from "./setup.js";
31
28
  import type { ControlPlaneState } from "./types.js";
32
29
  import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
33
- import { readConnectionProfilesDocument } from "./connection-profiles.js";
34
30
 
35
31
  // ── Helpers ──────────────────────────────────────────────────────────────
36
32
 
37
- function makeValidInput(overrides?: Partial<SetupInput>): SetupInput {
33
+ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
38
34
  return {
39
- adminToken: "test-admin-token-12345",
40
- ownerName: "Test User",
41
- ownerEmail: "test@example.com",
42
- memoryUserId: "test_user",
43
- ollamaEnabled: false,
35
+ version: 2,
36
+ capabilities: {
37
+ llm: "openai/gpt-4o",
38
+ embeddings: {
39
+ provider: "openai",
40
+ model: "text-embedding-3-small",
41
+ dims: 1536,
42
+ },
43
+ memory: {
44
+ userId: "test_user",
45
+ customInstructions: "",
46
+ },
47
+ },
48
+ security: { adminToken: "test-admin-token-12345" },
49
+ owner: { name: "Test User", email: "test@example.com" },
44
50
  connections: [
45
51
  {
46
52
  id: "openai-main",
@@ -50,121 +56,114 @@ function makeValidInput(overrides?: Partial<SetupInput>): SetupInput {
50
56
  apiKey: "sk-test-key-123",
51
57
  },
52
58
  ],
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
59
  ...overrides,
61
60
  };
62
61
  }
63
62
 
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
- };
63
+ /** Seed the minimal asset files that ensure* functions expect to find at OP_HOME. */
64
+ function seedRequiredAssets(homeDir: string): void {
65
+ mkdirSync(join(homeDir, "stack"), { recursive: true });
66
+ writeFileSync(join(homeDir, "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
67
+ mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
68
+ writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
69
+ writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
70
+ mkdirSync(join(homeDir, "vault", "user"), { recursive: true });
71
+ writeFileSync(join(homeDir, "vault", "user", "user.env.schema"), "ADMIN_TOKEN=string\n");
72
+ mkdirSync(join(homeDir, "vault", "stack"), { recursive: true });
73
+ writeFileSync(join(homeDir, "vault", "stack", "stack.env.schema"), "OP_IMAGE_TAG=string\n");
74
+ mkdirSync(join(homeDir, "config", "automations"), { recursive: true });
75
+ writeFileSync(join(homeDir, "config", "automations", "cleanup-logs.yml"), "name: cleanup-logs\nschedule: daily\n");
76
+ writeFileSync(join(homeDir, "config", "automations", "cleanup-data.yml"), "name: cleanup-data\nschedule: weekly\n");
77
+ writeFileSync(join(homeDir, "config", "automations", "validate-config.yml"), "name: validate-config\nschedule: hourly\n");
82
78
  }
83
79
 
84
80
  // ── Shared test fixture ──────────────────────────────────────────────────
85
81
 
86
- let tempBase: string;
82
+ let homeDir: string;
87
83
  let configDir: string;
84
+ let vaultDir: string;
88
85
  let dataDir: string;
89
- let stateDir: string;
86
+ let logsDir: string;
90
87
 
91
88
  const savedEnv: Record<string, string | undefined> = {};
92
89
 
93
90
  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;
91
+ savedEnv.OP_HOME = process.env.OP_HOME;
92
+ process.env.OP_HOME = homeDir;
100
93
  }
101
94
 
102
95
  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;
96
+ process.env.OP_HOME = savedEnv.OP_HOME;
106
97
  }
107
98
 
108
- /** Create a full directory tree matching ensureXdgDirs() output. */
99
+ /** Create a full directory tree matching ensureHomeDirs() output. */
109
100
  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");
101
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
102
+ configDir = join(homeDir, "config");
103
+ vaultDir = join(homeDir, "vault");
104
+ dataDir = join(homeDir, "data");
105
+ logsDir = join(homeDir, "logs");
114
106
 
115
107
  for (const dir of [
108
+ homeDir,
116
109
  configDir,
110
+ join(configDir, "automations"),
117
111
  join(configDir, "channels"),
118
- join(configDir, "connections"),
119
112
  join(configDir, "assistant"),
120
- join(configDir, "automations"),
121
113
  join(configDir, "stash"),
114
+ join(homeDir, "stack"),
115
+ join(homeDir, "stack", "addons"),
116
+ vaultDir,
122
117
  dataDir,
123
118
  join(dataDir, "admin"),
124
119
  join(dataDir, "memory"),
125
120
  join(dataDir, "assistant"),
126
121
  join(dataDir, "guardian"),
127
- join(dataDir, "caddy"),
128
- join(dataDir, "caddy", "data"),
129
- join(dataDir, "caddy", "config"),
130
122
  join(dataDir, "automations"),
131
123
  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"),
124
+ join(dataDir, "stash"),
125
+ join(dataDir, "workspace"),
126
+ logsDir,
127
+ join(logsDir, "opencode"),
138
128
  ]) {
139
129
  mkdirSync(dir, { recursive: true });
140
130
  }
131
+
132
+ // Seed asset files that ensure* functions expect to find at OP_HOME
133
+ seedRequiredAssets(homeDir);
141
134
  }
142
135
 
143
- /** Seed the minimal secrets.env and stack.env needed for most tests. */
136
+ /** Seed the minimal user.env and stack.env needed for most tests. */
144
137
  function seedMinimalEnvFiles(): void {
138
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
139
+ mkdirSync(join(vaultDir, "stack"), { recursive: true });
145
140
  writeFileSync(
146
- join(configDir, "secrets.env"),
141
+ join(vaultDir, "user", "user.env"),
147
142
  [
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=",
143
+ "# OpenPalm — User Extensions",
144
+ "# Add any custom environment variables here.",
145
+ "# These are loaded by compose alongside stack.env.",
161
146
  "",
162
147
  ].join("\n")
163
148
  );
164
149
 
165
150
  writeFileSync(
166
- join(stateDir, "artifacts", "stack.env"),
167
- "OPENPALM_SETUP_COMPLETE=false\n"
151
+ join(vaultDir, "stack", "stack.env"),
152
+ [
153
+ "# OpenPalm — Stack Configuration",
154
+ "OP_ADMIN_TOKEN=",
155
+ "OP_ASSISTANT_TOKEN=",
156
+ "OP_MEMORY_TOKEN=",
157
+ "OPENAI_API_KEY=",
158
+ "OPENAI_BASE_URL=",
159
+ "ANTHROPIC_API_KEY=",
160
+ "GROQ_API_KEY=",
161
+ "MISTRAL_API_KEY=",
162
+ "GOOGLE_API_KEY=",
163
+ "OWNER_NAME=",
164
+ "OWNER_EMAIL=",
165
+ "",
166
+ ].join("\n")
168
167
  );
169
168
  }
170
169
 
@@ -182,47 +181,54 @@ describe("Fresh Install", () => {
182
181
 
183
182
  afterEach(() => {
184
183
  restoreEnv();
185
- rmSync(tempBase, { recursive: true, force: true });
184
+ rmSync(homeDir, { recursive: true, force: true });
186
185
  });
187
186
 
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", () => {
187
+ // Scenario 1: ensureSecrets creates user.env as placeholder and stack.env with required keys
188
+ it("ensureSecrets creates user.env as placeholder and stack.env with required keys when files do not exist", () => {
190
189
  const state: ControlPlaneState = {
191
190
  adminToken: "",
191
+ assistantToken: "",
192
192
  setupToken: "",
193
- stateDir,
193
+ homeDir,
194
194
  configDir,
195
+ vaultDir,
195
196
  dataDir,
197
+ logsDir,
198
+ cacheDir: join(homeDir, "cache"),
196
199
  services: {},
197
- artifacts: { compose: "", caddyfile: "" },
200
+ artifacts: { compose: "" },
198
201
  artifactMeta: [],
199
202
  audit: [],
200
- channelSecrets: {},
201
203
  };
202
204
 
203
- // No secrets.env exists yet
204
- expect(existsSync(join(configDir, "secrets.env"))).toBe(false);
205
+ // No user.env exists yet
206
+ expect(existsSync(join(vaultDir, "user", "user.env"))).toBe(false);
205
207
 
206
208
  ensureSecrets(state);
207
209
 
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);
210
+ // user.env is now a minimal placeholder
211
+ const userContent = readFileSync(join(vaultDir, "user", "user.env"), "utf-8");
212
+ expect(userContent).toContain("User Extensions");
213
+
214
+ // API keys and owner info are seeded in stack.env
215
+ const stackContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
216
+ expect(stackContent).toContain("OPENAI_API_KEY=");
217
+ expect(stackContent).toContain("OWNER_NAME=");
214
218
  });
215
219
 
216
220
  // Scenario 2: isSetupComplete returns false before setup
217
- it("isSetupComplete returns false when stack.env has OPENPALM_SETUP_COMPLETE=false", () => {
221
+ it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
222
+ mkdirSync(join(vaultDir, "stack"), { recursive: true });
223
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
218
224
  writeFileSync(
219
- join(stateDir, "artifacts", "stack.env"),
220
- "OPENPALM_SETUP_COMPLETE=false\n"
225
+ join(vaultDir, "stack", "stack.env"),
226
+ "OP_SETUP_COMPLETE=false\n"
221
227
  );
222
- // Empty secrets.env so fallback check doesn't trigger
223
- writeFileSync(join(configDir, "secrets.env"), "");
228
+ // Empty user.env so fallback check doesn't trigger
229
+ writeFileSync(join(vaultDir, "user", "user.env"), "");
224
230
 
225
- expect(isSetupComplete(stateDir, configDir)).toBe(false);
231
+ expect(isSetupComplete(vaultDir)).toBe(false);
226
232
  });
227
233
 
228
234
  // Scenario 3: performSetup succeeds from completely empty state
@@ -230,20 +236,21 @@ describe("Fresh Install", () => {
230
236
  seedMinimalEnvFiles();
231
237
 
232
238
  const result = await performSetup(
233
- makeValidInput(),
234
- createStubAssetProvider()
239
+ makeValidSpec()
235
240
  );
236
241
 
237
242
  expect(result.ok).toBe(true);
238
243
  });
239
244
 
240
- // Scenario 4: isSetupComplete returns true after performSetup
241
- it("isSetupComplete returns true after performSetup", async () => {
245
+ // Scenario 4: performSetup marks setup complete in vault/stack/stack.env
246
+ it("performSetup marks OP_SETUP_COMPLETE=true in vault stack.env", async () => {
242
247
  seedMinimalEnvFiles();
243
248
 
244
- await performSetup(makeValidInput(), createStubAssetProvider());
249
+ await performSetup(makeValidSpec());
245
250
 
246
- expect(isSetupComplete(stateDir, configDir)).toBe(true);
251
+ const stackEnv = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
252
+ const parsed = parseEnvContent(stackEnv);
253
+ expect(parsed.OP_SETUP_COMPLETE).toBe("true");
247
254
  });
248
255
  });
249
256
 
@@ -260,52 +267,56 @@ describe("Existing Install", () => {
260
267
 
261
268
  afterEach(() => {
262
269
  restoreEnv();
263
- rmSync(tempBase, { recursive: true, force: true });
270
+ rmSync(homeDir, { recursive: true, force: true });
264
271
  });
265
272
 
266
- // Scenario 5: ensureSecrets does NOT overwrite existing secrets.env
267
- it("ensureSecrets does not overwrite existing secrets.env", () => {
273
+ // Scenario 5: ensureSecrets does NOT overwrite existing user.env
274
+ it("ensureSecrets does not overwrite existing user.env", () => {
268
275
  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);
276
+ "export OP_ADMIN_TOKEN=my-custom-token\nexport OP_MEMORY_TOKEN=custom-auth-token\n";
277
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
278
+ writeFileSync(join(vaultDir, "user", "user.env"), customContent);
271
279
 
272
280
  const state: ControlPlaneState = {
273
281
  adminToken: "",
282
+ assistantToken: "",
274
283
  setupToken: "",
275
- stateDir,
284
+ homeDir,
276
285
  configDir,
286
+ vaultDir,
277
287
  dataDir,
288
+ logsDir,
289
+ cacheDir: join(homeDir, "cache"),
278
290
  services: {},
279
- artifacts: { compose: "", caddyfile: "" },
291
+ artifacts: { compose: "" },
280
292
  artifactMeta: [],
281
293
  audit: [],
282
- channelSecrets: {},
283
294
  };
284
295
 
285
296
  ensureSecrets(state);
286
297
 
287
- const afterContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
298
+ const afterContent = readFileSync(join(vaultDir, "user", "user.env"), "utf-8");
288
299
  expect(afterContent).toBe(customContent);
289
300
  });
290
301
 
291
- // Scenario 6: performSetup re-run preserves MEMORY_AUTH_TOKEN
292
- it("performSetup re-run preserves MEMORY_AUTH_TOKEN from first run", async () => {
302
+ // Scenario 6: performSetup re-run preserves OP_MEMORY_TOKEN
303
+ it("performSetup re-run preserves OP_MEMORY_TOKEN from first run", async () => {
293
304
  // First setup
294
- await performSetup(makeValidInput(), createStubAssetProvider());
305
+ await performSetup(makeValidSpec());
295
306
 
296
307
  const secretsAfterFirst = readFileSync(
297
- join(configDir, "secrets.env"),
308
+ join(vaultDir, "stack", "stack.env"),
298
309
  "utf-8"
299
310
  );
300
311
  const firstMatch = secretsAfterFirst.match(
301
- /MEMORY_AUTH_TOKEN=([a-f0-9]+)/
312
+ /OP_MEMORY_TOKEN=([a-f0-9]+)/
302
313
  );
303
314
  expect(firstMatch).not.toBeNull();
304
315
  const firstToken = firstMatch![1];
305
316
 
306
317
  // Second setup (re-run with different API key)
307
318
  await performSetup(
308
- makeValidInput({
319
+ makeValidSpec({
309
320
  connections: [
310
321
  {
311
322
  id: "openai-main",
@@ -315,46 +326,57 @@ describe("Existing Install", () => {
315
326
  apiKey: "sk-different-key-999",
316
327
  },
317
328
  ],
318
- }),
319
- createStubAssetProvider()
329
+ })
320
330
  );
321
331
 
322
332
  const secretsAfterSecond = readFileSync(
323
- join(configDir, "secrets.env"),
333
+ join(vaultDir, "stack", "stack.env"),
324
334
  "utf-8"
325
335
  );
326
336
  const secondMatch = secretsAfterSecond.match(
327
- /MEMORY_AUTH_TOKEN=([a-f0-9]+)/
337
+ /OP_MEMORY_TOKEN=([a-f0-9]+)/
328
338
  );
329
339
  expect(secondMatch).not.toBeNull();
330
- // MEMORY_AUTH_TOKEN should be preserved (buildSecretsFromSetup does not overwrite it)
340
+ // OP_MEMORY_TOKEN should be preserved (buildSystemSecretsFromSetup does not overwrite it)
331
341
  expect(secondMatch![1]).toBe(firstToken);
332
342
  });
333
343
 
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());
344
+ // Scenario 7: performSetup marks OP_SETUP_COMPLETE=true in vault/stack/stack.env
345
+ it("performSetup marks OP_SETUP_COMPLETE=true in vault stack.env", async () => {
346
+ await performSetup(makeValidSpec());
337
347
 
338
- const stagedStack = readFileSync(
339
- join(stateDir, "artifacts", "stack.env"),
348
+ const stackEnv = readFileSync(
349
+ join(vaultDir, "stack", "stack.env"),
340
350
  "utf-8"
341
351
  );
342
- const parsed = parseEnvContent(stagedStack);
343
- expect(parsed.OPENPALM_SETUP_COMPLETE).toBe("true");
352
+ const parsed = parseEnvContent(stackEnv);
353
+ expect(parsed.OP_SETUP_COMPLETE).toBe("true");
344
354
  });
345
355
 
346
- // Scenario 8: Re-setup with different provider preserves existing connections
347
- it("re-setup with different provider writes new connection profiles", async () => {
356
+ // Scenario 8: Re-setup with different provider updates stack.yml capabilities
357
+ it("re-setup with different provider updates capabilities in stack.yml", async () => {
348
358
  // First setup with OpenAI
349
- await performSetup(makeValidInput(), createStubAssetProvider());
359
+ await performSetup(makeValidSpec());
350
360
 
351
- const profilesAfterFirst = readConnectionProfilesDocument(configDir);
352
- expect(profilesAfterFirst.profiles).toHaveLength(1);
353
- expect(profilesAfterFirst.profiles[0].provider).toBe("openai");
361
+ const specAfterFirst = readStackSpec(configDir);
362
+ expect(specAfterFirst).not.toBeNull();
363
+ expect(specAfterFirst!.capabilities.llm).toContain("openai/");
354
364
 
355
365
  // Second setup with Groq
356
366
  await performSetup(
357
- makeValidInput({
367
+ makeValidSpec({
368
+ capabilities: {
369
+ llm: "groq/llama3-70b-8192",
370
+ embeddings: {
371
+ provider: "groq",
372
+ model: "text-embedding-3-small",
373
+ dims: 1536,
374
+ },
375
+ memory: {
376
+ userId: "test_user",
377
+ customInstructions: "",
378
+ },
379
+ },
358
380
  connections: [
359
381
  {
360
382
  id: "groq-main",
@@ -364,24 +386,15 @@ describe("Existing Install", () => {
364
386
  apiKey: "gsk-test-key-456",
365
387
  },
366
388
  ],
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()
389
+ })
376
390
  );
377
391
 
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");
392
+ const specAfterSecond = readStackSpec(configDir);
393
+ expect(specAfterSecond).not.toBeNull();
394
+ expect(specAfterSecond!.capabilities.llm).toBe("groq/llama3-70b-8192");
382
395
 
383
- // But secrets.env should retain both keys
384
- const secrets = readFileSync(join(configDir, "secrets.env"), "utf-8");
396
+ // stack.env should retain both keys
397
+ const secrets = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
385
398
  expect(secrets).toContain("GROQ_API_KEY");
386
399
  });
387
400
  });
@@ -398,34 +411,38 @@ describe("Broken/Corrupt State", () => {
398
411
 
399
412
  afterEach(() => {
400
413
  restoreEnv();
401
- rmSync(tempBase, { recursive: true, force: true });
414
+ rmSync(homeDir, { recursive: true, force: true });
402
415
  });
403
416
 
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"), "");
417
+ // Scenario 9: user.env exists but is empty
418
+ it("ensureSecrets returns early for an empty but existing user.env", () => {
419
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
420
+ writeFileSync(join(vaultDir, "user", "user.env"), "");
407
421
 
408
422
  const state: ControlPlaneState = {
409
423
  adminToken: "",
424
+ assistantToken: "",
410
425
  setupToken: "",
411
- stateDir,
426
+ homeDir,
412
427
  configDir,
428
+ vaultDir,
413
429
  dataDir,
430
+ logsDir,
431
+ cacheDir: join(homeDir, "cache"),
414
432
  services: {},
415
- artifacts: { compose: "", caddyfile: "" },
433
+ artifacts: { compose: "" },
416
434
  artifactMeta: [],
417
435
  audit: [],
418
- channelSecrets: {},
419
436
  };
420
437
 
421
438
  ensureSecrets(state);
422
439
 
423
440
  // File should still exist and still be empty (ensureSecrets only checks existence)
424
- const content = readFileSync(join(configDir, "secrets.env"), "utf-8");
441
+ const content = readFileSync(join(vaultDir, "user", "user.env"), "utf-8");
425
442
  expect(content).toBe("");
426
443
  });
427
444
 
428
- // Scenario 10: secrets.env with malformed lines
445
+ // Scenario 10: user.env with malformed lines
429
446
  it("parseEnvFile handles malformed env lines gracefully", () => {
430
447
  const malformedContent = [
431
448
  "# Comment line",
@@ -439,43 +456,42 @@ describe("Broken/Corrupt State", () => {
439
456
  " # indented comment",
440
457
  ].join("\n");
441
458
 
442
- writeFileSync(join(configDir, "secrets.env"), malformedContent);
459
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
460
+ writeFileSync(join(vaultDir, "user", "user.env"), malformedContent);
443
461
 
444
- const parsed = parseEnvFile(join(configDir, "secrets.env"));
462
+ const parsed = parseEnvFile(join(vaultDir, "user", "user.env"));
445
463
  expect(parsed.VALID_KEY).toBe("valid_value");
446
464
  expect(parsed.EXPORTED_KEY).toBe("exported_value");
447
465
  expect(parsed.ANOTHER_VALID).toBe("value");
448
466
  });
449
467
 
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
468
+ // Scenario 11: stack.env missing OP_SETUP_COMPLETE
469
+ it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
470
+ // stack.env without OP_SETUP_COMPLETE
471
+ mkdirSync(join(vaultDir, "stack"), { recursive: true });
472
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
453
473
  writeFileSync(
454
- join(stateDir, "artifacts", "stack.env"),
455
- "OPENPALM_IMAGE_TAG=latest\n"
474
+ join(vaultDir, "stack", "stack.env"),
475
+ "OP_IMAGE_TAG=latest\n"
456
476
  );
457
477
 
458
- // secrets.env without any token
478
+ // user.env without any token
459
479
  writeFileSync(
460
- join(configDir, "secrets.env"),
461
- "export OPENPALM_ADMIN_TOKEN=\nexport ADMIN_TOKEN=\n"
480
+ join(vaultDir, "user", "user.env"),
481
+ "export OP_ADMIN_TOKEN=\nexport ADMIN_TOKEN=\n"
462
482
  );
463
483
 
464
- expect(isSetupComplete(stateDir, configDir)).toBe(false);
484
+ expect(isSetupComplete(vaultDir)).toBe(false);
465
485
  });
466
486
 
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
-
487
+ it("isSetupComplete falls back to true when admin token is set but OP_SETUP_COMPLETE missing", () => {
488
+ mkdirSync(join(vaultDir, "stack"), { recursive: true });
473
489
  writeFileSync(
474
- join(configDir, "secrets.env"),
475
- "export OPENPALM_ADMIN_TOKEN=my-real-token\n"
490
+ join(vaultDir, "stack", "stack.env"),
491
+ "OP_IMAGE_TAG=latest\nexport OP_ADMIN_TOKEN=my-real-token\n"
476
492
  );
477
493
 
478
- expect(isSetupComplete(stateDir, configDir)).toBe(true);
494
+ expect(isSetupComplete(vaultDir)).toBe(true);
479
495
  });
480
496
 
481
497
  // Scenario 12: API key with special characters round-trips
@@ -494,57 +510,38 @@ describe("Broken/Corrupt State", () => {
494
510
  }
495
511
  });
496
512
 
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
- );
513
+ // Scenario 13: Missing stack.yml returns null
514
+ it("readStackSpec returns null when stack.yml missing", () => {
515
+ const spec = readStackSpec(configDir);
516
+ expect(spec).toBeNull();
518
517
  });
519
518
 
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)
519
+ // Scenario 14: config dir exists but automations dir doesn't
520
+ it("performSetup creates missing subdirectories", async () => {
521
+ // Seed the minimal env files first
523
522
  seedMinimalEnvFiles();
524
523
 
525
524
  // Remove automations dir (performSetup should recreate it)
526
- rmSync(join(stateDir, "automations"), { recursive: true, force: true });
525
+ rmSync(join(configDir, "automations"), { recursive: true, force: true });
527
526
 
528
527
  const result = await performSetup(
529
- makeValidInput(),
530
- createStubAssetProvider()
528
+ makeValidSpec()
531
529
  );
532
530
  expect(result.ok).toBe(true);
533
531
 
534
- // Artifacts should exist
535
- expect(existsSync(join(stateDir, "artifacts", "docker-compose.yml"))).toBe(
532
+ // Artifacts should exist in stack/ (not config/components/)
533
+ expect(existsSync(join(homeDir, "stack", "core.compose.yml"))).toBe(
536
534
  true
537
535
  );
538
- expect(existsSync(join(stateDir, "artifacts", "Caddyfile"))).toBe(true);
539
536
  // Automations dir should be recreated
540
- expect(existsSync(join(stateDir, "automations"))).toBe(true);
537
+ expect(existsSync(join(configDir, "automations"))).toBe(true);
541
538
  });
542
539
 
543
540
  // Scenario 15: openpalm.yaml with old version
544
- it("readStackSpec returns null for version 2 spec", () => {
541
+ it("readStackSpec returns null for version 1 spec", () => {
545
542
  writeFileSync(
546
543
  join(configDir, STACK_SPEC_FILENAME),
547
- "version: 2\nservices: []\n"
544
+ "version: 1\nconnections: []\n"
548
545
  );
549
546
 
550
547
  const spec = readStackSpec(configDir);
@@ -564,26 +561,18 @@ describe("Environment Edge Cases", () => {
564
561
 
565
562
  afterEach(() => {
566
563
  restoreEnv();
567
- rmSync(tempBase, { recursive: true, force: true });
564
+ rmSync(homeDir, { recursive: true, force: true });
568
565
  });
569
566
 
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
-
567
+ // Scenario 16: Commented-out ADMIN_TOKEN but OP_ADMIN_TOKEN set
568
+ it("isSetupComplete detects OP_ADMIN_TOKEN when ADMIN_TOKEN is commented out", () => {
569
+ mkdirSync(join(vaultDir, "stack"), { recursive: true });
577
570
  writeFileSync(
578
- join(configDir, "secrets.env"),
579
- [
580
- "export OPENPALM_ADMIN_TOKEN=real-token-here",
581
- "# export ADMIN_TOKEN=",
582
- "",
583
- ].join("\n")
571
+ join(vaultDir, "stack", "stack.env"),
572
+ "SOME_OTHER_KEY=value\nexport OP_ADMIN_TOKEN=real-token-here\n"
584
573
  );
585
574
 
586
- expect(isSetupComplete(stateDir, configDir)).toBe(true);
575
+ expect(isSetupComplete(vaultDir)).toBe(true);
587
576
  });
588
577
 
589
578
  // Scenario 17: export prefix on env vars
@@ -636,13 +625,24 @@ describe("Setup Input Variations", () => {
636
625
 
637
626
  afterEach(() => {
638
627
  restoreEnv();
639
- rmSync(tempBase, { recursive: true, force: true });
628
+ rmSync(homeDir, { recursive: true, force: true });
640
629
  });
641
630
 
642
631
  // Scenario 20: Ollama in-stack setup
643
632
  it("Ollama in-stack setup overrides localhost URL to docker-internal", async () => {
644
- const input = makeValidInput({
645
- ollamaEnabled: true,
633
+ const input = makeValidSpec({
634
+ capabilities: {
635
+ llm: "ollama/llama3.2",
636
+ embeddings: {
637
+ provider: "ollama",
638
+ model: "nomic-embed-text",
639
+ dims: 768,
640
+ },
641
+ memory: {
642
+ userId: "test_user",
643
+ customInstructions: "",
644
+ },
645
+ },
646
646
  connections: [
647
647
  {
648
648
  id: "ollama-local",
@@ -652,135 +652,53 @@ describe("Setup Input Variations", () => {
652
652
  apiKey: "",
653
653
  },
654
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
655
  });
663
656
 
664
- const result = await performSetup(input, createStubAssetProvider());
657
+ const result = await performSetup(input);
665
658
  expect(result.ok).toBe(true);
666
659
 
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");
660
+ // stack.yml should have ollama capabilities
661
+ const spec = readStackSpec(configDir);
662
+ expect(spec).not.toBeNull();
663
+ expect(spec!.capabilities.llm).toBe("ollama/llama3.2");
675
664
  });
676
665
 
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
- },
666
+ // Scenario 21: Multiple providers map to correct env vars
667
+ it("multiple providers each write their API key to the correct env var", () => {
668
+ const conns: SetupConnection[] = [
669
+ { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-openai" },
670
+ { id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-groq" },
671
+ { id: "anthropic-1", name: "Anthropic", provider: "anthropic", baseUrl: "", apiKey: "sk-ant-api03" },
701
672
  ];
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");
673
+ const secrets = buildSecretsFromSetup(conns);
674
+ expect(secrets.OPENAI_API_KEY).toBe("sk-openai");
675
+ expect(secrets.GROQ_API_KEY).toBe("gsk-groq");
676
+ expect(secrets.ANTHROPIC_API_KEY).toBe("sk-ant-api03");
707
677
  });
708
678
 
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");
679
+ // Scenario 21b: OAuth providers (no API key) are silently skipped
680
+ it("skips connections without API keys (OAuth providers)", () => {
681
+ const conns: SetupConnection[] = [
682
+ { id: "github-copilot", name: "GitHub Copilot", provider: "github-copilot", baseUrl: "", apiKey: "" },
683
+ { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-test" },
684
+ ];
685
+ const secrets = buildSecretsFromSetup(conns);
686
+ expect(secrets.OPENAI_API_KEY).toBe("sk-test");
687
+ expect(Object.keys(secrets)).not.toContain("GITHUB_COPILOT_API_KEY");
734
688
  });
735
689
 
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
- );
690
+ // Scenario 22: buildSecretsFromSetup only writes API keys and owner info
691
+ it("buildSecretsFromSetup writes API keys but not config vars", () => {
692
+ const spec = makeValidSpec();
693
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
757
694
 
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");
695
+ // API key should be written
696
+ expect(secrets.OPENAI_API_KEY).toBe("sk-test-key-123");
697
+ // Config vars should NOT be in user.env anymore
698
+ expect(secrets.SYSTEM_LLM_PROVIDER).toBeUndefined();
699
+ expect(secrets.SYSTEM_LLM_MODEL).toBeUndefined();
700
+ expect(secrets.EMBEDDING_MODEL).toBeUndefined();
701
+ expect(secrets.EMBEDDING_DIMS).toBeUndefined();
784
702
  });
785
703
  });
786
704
 
@@ -797,22 +715,33 @@ describe("performSetup end-to-end artifacts", () => {
797
715
 
798
716
  afterEach(() => {
799
717
  restoreEnv();
800
- rmSync(tempBase, { recursive: true, force: true });
718
+ rmSync(homeDir, { recursive: true, force: true });
801
719
  });
802
720
 
803
- it("writes openpalm.yaml with version 3", async () => {
804
- await performSetup(makeValidInput(), createStubAssetProvider());
721
+ it("writes stack.yml and readStackSpec returns v2", async () => {
722
+ await performSetup(makeValidSpec());
805
723
 
806
724
  const spec = readStackSpec(configDir);
807
725
  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);
726
+ expect(spec!.version).toBe(2);
727
+ expect(spec!.capabilities.llm).toBe("openai/gpt-4o");
728
+ expect(spec!.capabilities.embeddings.model).toBe("text-embedding-3-small");
812
729
  });
813
730
 
814
- it("writes memory config with correct embedding dims from lookup", async () => {
815
- const input = makeValidInput({
731
+ it("writes OP_CAP_EMBEDDINGS_DIMS with correct embedding dims from lookup", async () => {
732
+ const input = makeValidSpec({
733
+ capabilities: {
734
+ llm: "ollama/llama3.2",
735
+ embeddings: {
736
+ provider: "ollama",
737
+ model: "nomic-embed-text",
738
+ dims: 0, // Resolved from lookup
739
+ },
740
+ memory: {
741
+ userId: "test_user",
742
+ customInstructions: "",
743
+ },
744
+ },
816
745
  connections: [
817
746
  {
818
747
  id: "ollama-1",
@@ -822,54 +751,39 @@ describe("performSetup end-to-end artifacts", () => {
822
751
  apiKey: "",
823
752
  },
824
753
  ],
825
- assignments: {
826
- llm: { connectionId: "ollama-1", model: "llama3.2" },
827
- embeddings: {
828
- connectionId: "ollama-1",
829
- model: "nomic-embed-text",
830
- },
831
- },
832
754
  });
833
755
 
834
- await performSetup(input, createStubAssetProvider());
756
+ await performSetup(input);
835
757
 
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);
758
+ // nomic-embed-text is 768 dims per EMBEDDING_DIMS constant — verify via stack.env
759
+ const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
760
+ expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_DIMS=768");
841
761
  });
842
762
 
843
- it("writes docker-compose.yml and Caddyfile to STATE_HOME/artifacts", async () => {
844
- await performSetup(makeValidInput(), createStubAssetProvider());
763
+ it("writes core.compose.yml to stack/", async () => {
764
+ await performSetup(makeValidSpec());
845
765
 
846
766
  expect(
847
- existsSync(join(stateDir, "artifacts", "docker-compose.yml"))
767
+ existsSync(join(homeDir, "stack", "core.compose.yml"))
848
768
  ).toBe(true);
849
- expect(existsSync(join(stateDir, "artifacts", "Caddyfile"))).toBe(true);
850
- expect(existsSync(join(stateDir, "artifacts", "manifest.json"))).toBe(
851
- true
852
- );
853
769
  });
854
770
 
855
- it("writes secrets.env with correct admin token to both OPENPALM_ADMIN_TOKEN and ADMIN_TOKEN", async () => {
856
- await performSetup(makeValidInput(), createStubAssetProvider());
771
+ it("writes admin and assistant tokens to stack.env", async () => {
772
+ await performSetup(makeValidSpec());
857
773
 
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");
774
+ const secrets = parseEnvFile(join(vaultDir, "stack", "stack.env"));
775
+ expect(secrets.OP_ADMIN_TOKEN).toBe("test-admin-token-12345");
776
+ expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
777
+ expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
861
778
  });
862
779
 
863
- it("creates connection profiles document with correct assignments", async () => {
864
- await performSetup(makeValidInput(), createStubAssetProvider());
780
+ it("writes OP_CAP_* vars from capabilities to stack.env", async () => {
781
+ await performSetup(makeValidSpec());
865
782
 
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");
783
+ const stackEnv = parseEnvFile(join(vaultDir, "stack", "stack.env"));
784
+ expect(stackEnv.OP_CAP_LLM_PROVIDER).toBe("openai");
785
+ expect(stackEnv.OP_CAP_LLM_MODEL).toBe("gpt-4o");
786
+ expect(stackEnv.OP_CAP_EMBEDDINGS_MODEL).toBe("text-embedding-3-small");
873
787
  });
874
788
  });
875
789
 
@@ -894,321 +808,4 @@ describe("mergeEnvContent edge cases", () => {
894
808
  const parsed = parseEnvContent(result);
895
809
  expect(parsed.FOO).toBe("new");
896
810
  });
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
811
  });