@openpalm/lib 0.10.2 → 0.11.0-beta.2

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 (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
@@ -22,6 +22,7 @@ import { isSetupComplete } from "./setup-status.js";
22
22
  import {
23
23
  performSetup,
24
24
  buildSecretsFromSetup,
25
+ buildAuthJsonFromSetup,
25
26
  buildSystemSecretsFromSetup,
26
27
  } from "./setup.js";
27
28
  import type { SetupSpec, SetupConnection } from "./setup.js";
@@ -33,19 +34,9 @@ import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
33
34
  function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
34
35
  return {
35
36
  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" },
37
+ llm: { provider: "openai", model: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
38
+ embedding: { provider: "openai", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.openai.com/v1" },
39
+ security: { uiLoginPassword: "test-admin-token-12345" },
49
40
  owner: { name: "Test User", email: "test@example.com" },
50
41
  connections: [
51
42
  {
@@ -62,28 +53,26 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
62
53
 
63
54
  /** Seed the minimal asset files that ensure* functions expect to find at OP_HOME. */
64
55
  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");
56
+ mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
57
+ writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
58
+ mkdirSync(join(homeDir, "state", "assistant"), { recursive: true });
59
+ writeFileSync(join(homeDir, "state", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
60
+ writeFileSync(join(homeDir, "state", "assistant", "AGENTS.md"), "# Agents\n");
61
+ mkdirSync(join(homeDir, "state"), { recursive: true });
62
+ // Automations live in state/registry/automations (shipped catalog) and stash/tasks (user tasks)
63
+ mkdirSync(join(homeDir, "state", "registry", "automations"), { recursive: true });
64
+ writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-logs.md"), "---\nschedule: \"0 4 * * 0\"\ndescription: cleanup logs\n---\n");
65
+ writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-data.md"), "---\nschedule: \"0 5 * * 0\"\ndescription: cleanup data\n---\n");
66
+ writeFileSync(join(homeDir, "state", "registry", "automations", "validate-config.md"), "---\nschedule: \"0 3 * * *\"\ndescription: validate config\n---\n");
78
67
  }
79
68
 
80
69
  // ── Shared test fixture ──────────────────────────────────────────────────
81
70
 
82
71
  let homeDir: string;
83
72
  let configDir: string;
84
- let vaultDir: string;
85
- let dataDir: string;
86
- let logsDir: string;
73
+ let stateDir: string;
74
+ let stackDir: string;
75
+ let cacheDir: string;
87
76
 
88
77
  const savedEnv: Record<string, string | undefined> = {};
89
78
 
@@ -100,31 +89,36 @@ function restoreEnv(): void {
100
89
  function createFullDirTree(): void {
101
90
  homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
102
91
  configDir = join(homeDir, "config");
103
- vaultDir = join(homeDir, "vault");
104
- dataDir = join(homeDir, "data");
105
- logsDir = join(homeDir, "logs");
92
+ stateDir = join(homeDir, "state");
93
+ stackDir = join(configDir, "stack");
94
+ cacheDir = join(homeDir, "cache");
106
95
 
107
96
  for (const dir of [
108
97
  homeDir,
109
98
  configDir,
110
- join(configDir, "automations"),
111
- join(configDir, "channels"),
99
+ join(homeDir, "state", "registry", "automations"),
112
100
  join(configDir, "assistant"),
113
- join(configDir, "stash"),
114
- join(homeDir, "stack"),
115
- join(homeDir, "stack", "addons"),
116
- vaultDir,
117
- dataDir,
118
- join(dataDir, "admin"),
119
- join(dataDir, "memory"),
120
- join(dataDir, "assistant"),
121
- join(dataDir, "guardian"),
122
- join(dataDir, "automations"),
123
- join(dataDir, "opencode"),
124
- join(dataDir, "stash"),
125
- join(dataDir, "workspace"),
126
- logsDir,
127
- join(logsDir, "opencode"),
101
+ join(configDir, "akm"),
102
+ join(homeDir, "stash"),
103
+ join(homeDir, "workspace"),
104
+ stackDir,
105
+ join(stackDir, "addons"),
106
+ stateDir,
107
+ join(stateDir, "assistant"),
108
+ join(stateDir, "admin"),
109
+ join(stateDir, "guardian"),
110
+ join(stateDir, "logs"),
111
+ join(stateDir, "logs", "opencode"),
112
+ join(stateDir, "registry"),
113
+ join(stateDir, "registry", "addons"),
114
+ join(stateDir, "backups"),
115
+ join(stateDir, "akm"),
116
+ join(stateDir, "akm", "data"),
117
+ join(stateDir, "akm", "state"),
118
+ cacheDir,
119
+ join(cacheDir, "akm"),
120
+ join(cacheDir, "guardian"),
121
+ join(cacheDir, "rollback"),
128
122
  ]) {
129
123
  mkdirSync(dir, { recursive: true });
130
124
  }
@@ -133,27 +127,15 @@ function createFullDirTree(): void {
133
127
  seedRequiredAssets(homeDir);
134
128
  }
135
129
 
136
- /** Seed the minimal user.env and stack.env needed for most tests. */
130
+ /** Seed the minimal stack.env needed for most tests. */
137
131
  function seedMinimalEnvFiles(): void {
138
- mkdirSync(join(vaultDir, "user"), { recursive: true });
139
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
140
- writeFileSync(
141
- join(vaultDir, "user", "user.env"),
142
- [
143
- "# OpenPalm — User Extensions",
144
- "# Add any custom environment variables here.",
145
- "# These are loaded by compose alongside stack.env.",
146
- "",
147
- ].join("\n")
148
- );
132
+ mkdirSync(stackDir, { recursive: true });
149
133
 
150
134
  writeFileSync(
151
- join(vaultDir, "stack", "stack.env"),
135
+ join(stackDir, "stack.env"),
152
136
  [
153
137
  "# OpenPalm — Stack Configuration",
154
- "OP_ADMIN_TOKEN=",
155
- "OP_ASSISTANT_TOKEN=",
156
- "OP_MEMORY_TOKEN=",
138
+ "OP_UI_LOGIN_PASSWORD=",
157
139
  "OPENAI_API_KEY=",
158
140
  "OPENAI_BASE_URL=",
159
141
  "ANTHROPIC_API_KEY=",
@@ -184,51 +166,39 @@ describe("Fresh Install", () => {
184
166
  rmSync(homeDir, { recursive: true, force: true });
185
167
  });
186
168
 
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", () => {
169
+ // Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
170
+ // does create stack.env with required keys when files do not exist.
171
+ it("ensureSecrets creates state/stack.env with required keys on fresh install", () => {
189
172
  const state: ControlPlaneState = {
190
- adminToken: "",
191
- assistantToken: "",
192
- setupToken: "",
193
173
  homeDir,
194
174
  configDir,
195
- vaultDir,
196
- dataDir,
197
- logsDir,
198
- cacheDir: join(homeDir, "cache"),
175
+ stashDir: join(homeDir, "stash"),
176
+ workspaceDir: join(homeDir, "workspace"),
177
+ cacheDir,
178
+ stateDir,
179
+ stackDir,
199
180
  services: {},
200
181
  artifacts: { compose: "" },
201
182
  artifactMeta: [],
202
- audit: [],
203
183
  };
204
184
 
205
- // No user.env exists yet
206
- expect(existsSync(join(vaultDir, "user", "user.env"))).toBe(false);
207
-
208
185
  ensureSecrets(state);
209
186
 
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");
187
+ // API keys and owner info are seeded in state/stack.env.
188
+ const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
216
189
  expect(stackContent).toContain("OPENAI_API_KEY=");
217
190
  expect(stackContent).toContain("OWNER_NAME=");
218
191
  });
219
192
 
220
193
  // Scenario 2: isSetupComplete returns false before setup
221
194
  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 });
195
+ mkdirSync(stateDir, { recursive: true });
224
196
  writeFileSync(
225
- join(vaultDir, "stack", "stack.env"),
197
+ join(stackDir, "stack.env"),
226
198
  "OP_SETUP_COMPLETE=false\n"
227
199
  );
228
- // Empty user.env so fallback check doesn't trigger
229
- writeFileSync(join(vaultDir, "user", "user.env"), "");
230
200
 
231
- expect(isSetupComplete(vaultDir)).toBe(false);
201
+ expect(isSetupComplete(stackDir)).toBe(false);
232
202
  });
233
203
 
234
204
  // Scenario 3: performSetup succeeds from completely empty state
@@ -242,15 +212,21 @@ describe("Fresh Install", () => {
242
212
  expect(result.ok).toBe(true);
243
213
  });
244
214
 
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 () => {
215
+ // Scenario 4: performSetup must NOT mark OP_SETUP_COMPLETE.
216
+ //
217
+ // The flag is set by setup-deploy.ts:startDeploy AFTER the Docker stack is
218
+ // confirmed healthy. If performSetup wrote it eagerly, a deploy failure
219
+ // would leave the wizard convinced setup was complete and bounce the user
220
+ // into a broken admin UI.
221
+ it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
247
222
  seedMinimalEnvFiles();
248
223
 
249
224
  await performSetup(makeValidSpec());
250
225
 
251
- const stackEnv = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
226
+ const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
252
227
  const parsed = parseEnvContent(stackEnv);
253
- expect(parsed.OP_SETUP_COMPLETE).toBe("true");
228
+ // Either entirely absent, or still the seeded "false" — never "true".
229
+ expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
254
230
  });
255
231
  });
256
232
 
@@ -270,113 +246,71 @@ describe("Existing Install", () => {
270
246
  rmSync(homeDir, { recursive: true, force: true });
271
247
  });
272
248
 
273
- // Scenario 5: ensureSecrets does NOT overwrite existing user.env
274
- it("ensureSecrets does not overwrite existing user.env", () => {
275
- const 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);
249
+ // Scenario 5: ensureSecrets does NOT overwrite existing stack.env
250
+ it("ensureSecrets does not overwrite existing stack.env tokens", () => {
251
+ mkdirSync(stateDir, { recursive: true });
252
+ writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=my-custom-password\n");
279
253
 
280
254
  const state: ControlPlaneState = {
281
- adminToken: "",
282
- assistantToken: "",
283
- setupToken: "",
284
255
  homeDir,
285
256
  configDir,
286
- vaultDir,
287
- dataDir,
288
- logsDir,
289
- cacheDir: join(homeDir, "cache"),
257
+ stashDir: join(homeDir, "stash"),
258
+ workspaceDir: join(homeDir, "workspace"),
259
+ cacheDir,
260
+ stateDir,
261
+ stackDir,
290
262
  services: {},
291
263
  artifacts: { compose: "" },
292
264
  artifactMeta: [],
293
- audit: [],
294
265
  };
295
266
 
296
267
  ensureSecrets(state);
297
268
 
298
- const afterContent = readFileSync(join(vaultDir, "user", "user.env"), "utf-8");
299
- expect(afterContent).toBe(customContent);
269
+ // Existing password must be preserved
270
+ const afterContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
271
+ expect(afterContent).toContain("OP_UI_LOGIN_PASSWORD=my-custom-password");
300
272
  });
301
273
 
302
- // Scenario 6: performSetup re-run preserves OP_MEMORY_TOKEN
303
- it("performSetup re-run preserves OP_MEMORY_TOKEN from first run", async () => {
304
- // First setup
305
- await performSetup(makeValidSpec());
274
+ // Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
275
+ // operator supplies a new one in the spec. This is intentional — the
276
+ // wizard "rerun" path is how an operator rotates the password. The
277
+ // legacy OP_ASSISTANT_TOKEN preservation test was removed with the token.
278
+ it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when spec changes", async () => {
279
+ await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
306
280
 
307
- const secretsAfterFirst = readFileSync(
308
- join(vaultDir, "stack", "stack.env"),
309
- "utf-8"
310
- );
311
- const firstMatch = secretsAfterFirst.match(
312
- /OP_MEMORY_TOKEN=([a-f0-9]+)/
313
- );
314
- expect(firstMatch).not.toBeNull();
315
- const firstToken = firstMatch![1];
281
+ const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
282
+ expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
316
283
 
317
- // Second setup (re-run with different API key)
318
- await performSetup(
319
- makeValidSpec({
320
- connections: [
321
- {
322
- id: "openai-main",
323
- name: "OpenAI",
324
- provider: "openai",
325
- baseUrl: "https://api.openai.com",
326
- apiKey: "sk-different-key-999",
327
- },
328
- ],
329
- })
330
- );
284
+ await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
331
285
 
332
- const secretsAfterSecond = readFileSync(
333
- join(vaultDir, "stack", "stack.env"),
334
- "utf-8"
335
- );
336
- const secondMatch = secretsAfterSecond.match(
337
- /OP_MEMORY_TOKEN=([a-f0-9]+)/
338
- );
339
- expect(secondMatch).not.toBeNull();
340
- // OP_MEMORY_TOKEN should be preserved (buildSystemSecretsFromSetup does not overwrite it)
341
- expect(secondMatch![1]).toBe(firstToken);
286
+ const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
287
+ expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
342
288
  });
343
289
 
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 () => {
290
+ // Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE see scenario
291
+ // 4 in the Fresh Install block for the rationale. The deploy phase owns
292
+ // this flag and only writes it after the container stack is healthy.
293
+ it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
346
294
  await performSetup(makeValidSpec());
347
295
 
348
296
  const stackEnv = readFileSync(
349
- join(vaultDir, "stack", "stack.env"),
297
+ join(stackDir, "stack.env"),
350
298
  "utf-8"
351
299
  );
352
300
  const parsed = parseEnvContent(stackEnv);
353
- expect(parsed.OP_SETUP_COMPLETE).toBe("true");
301
+ expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
354
302
  });
355
303
 
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 () => {
304
+ // Scenario 8: Re-setup with different provider updates akm config
305
+ it("re-setup with different provider updates akm config", async () => {
358
306
  // First setup with OpenAI
359
307
  await performSetup(makeValidSpec());
360
308
 
361
- const specAfterFirst = readStackSpec(configDir);
362
- expect(specAfterFirst).not.toBeNull();
363
- expect(specAfterFirst!.capabilities.llm).toContain("openai/");
364
-
365
309
  // Second setup with Groq
366
310
  await performSetup(
367
311
  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
- },
312
+ llm: { provider: "groq", model: "llama3-70b-8192", baseUrl: "https://api.groq.com/openai/v1" },
313
+ embedding: { provider: "groq", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.groq.com/openai/v1" },
380
314
  connections: [
381
315
  {
382
316
  id: "groq-main",
@@ -389,12 +323,13 @@ describe("Existing Install", () => {
389
323
  })
390
324
  );
391
325
 
392
- const specAfterSecond = readStackSpec(configDir);
326
+ // stack.yml is just a version marker now
327
+ const specAfterSecond = readStackSpec(stackDir);
393
328
  expect(specAfterSecond).not.toBeNull();
394
- expect(specAfterSecond!.capabilities.llm).toBe("groq/llama3-70b-8192");
329
+ expect(specAfterSecond!.version).toBe(2);
395
330
 
396
331
  // stack.env should retain both keys
397
- const secrets = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
332
+ const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
398
333
  expect(secrets).toContain("GROQ_API_KEY");
399
334
  });
400
335
  });
@@ -414,35 +349,32 @@ describe("Broken/Corrupt State", () => {
414
349
  rmSync(homeDir, { recursive: true, force: true });
415
350
  });
416
351
 
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"), "");
352
+ // Scenario 9: ensureSecrets is idempotent on repeated calls
353
+ it("ensureSecrets is idempotent second call does not overwrite existing stack.env", () => {
354
+ mkdirSync(stateDir, { recursive: true });
355
+ writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=existing-password\n");
421
356
 
422
357
  const state: ControlPlaneState = {
423
- adminToken: "",
424
- assistantToken: "",
425
- setupToken: "",
426
358
  homeDir,
427
359
  configDir,
428
- vaultDir,
429
- dataDir,
430
- logsDir,
431
- cacheDir: join(homeDir, "cache"),
360
+ stashDir: join(homeDir, "stash"),
361
+ workspaceDir: join(homeDir, "workspace"),
362
+ cacheDir,
363
+ stateDir,
364
+ stackDir,
432
365
  services: {},
433
366
  artifacts: { compose: "" },
434
367
  artifactMeta: [],
435
- audit: [],
436
368
  };
437
369
 
438
370
  ensureSecrets(state);
439
371
 
440
- // File should still exist and still be empty (ensureSecrets only checks existence)
441
- const content = readFileSync(join(vaultDir, "user", "user.env"), "utf-8");
442
- expect(content).toBe("");
372
+ // Existing password must be preserved
373
+ const content = readFileSync(join(stackDir, "stack.env"), "utf-8");
374
+ expect(content).toContain("OP_UI_LOGIN_PASSWORD=existing-password");
443
375
  });
444
376
 
445
- // Scenario 10: user.env with malformed lines
377
+ // Scenario 10: env file with malformed lines
446
378
  it("parseEnvFile handles malformed env lines gracefully", () => {
447
379
  const malformedContent = [
448
380
  "# Comment line",
@@ -456,10 +388,10 @@ describe("Broken/Corrupt State", () => {
456
388
  " # indented comment",
457
389
  ].join("\n");
458
390
 
459
- mkdirSync(join(vaultDir, "user"), { recursive: true });
460
- writeFileSync(join(vaultDir, "user", "user.env"), malformedContent);
391
+ mkdirSync(stateDir, { recursive: true });
392
+ writeFileSync(join(stateDir, "test.env"), malformedContent);
461
393
 
462
- const parsed = parseEnvFile(join(vaultDir, "user", "user.env"));
394
+ const parsed = parseEnvFile(join(stateDir, "test.env"));
463
395
  expect(parsed.VALID_KEY).toBe("valid_value");
464
396
  expect(parsed.EXPORTED_KEY).toBe("exported_value");
465
397
  expect(parsed.ANOTHER_VALID).toBe("value");
@@ -468,30 +400,23 @@ describe("Broken/Corrupt State", () => {
468
400
  // Scenario 11: stack.env missing OP_SETUP_COMPLETE
469
401
  it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
470
402
  // stack.env without OP_SETUP_COMPLETE
471
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
472
- mkdirSync(join(vaultDir, "user"), { recursive: true });
403
+ mkdirSync(stateDir, { recursive: true });
473
404
  writeFileSync(
474
- join(vaultDir, "stack", "stack.env"),
405
+ join(stackDir, "stack.env"),
475
406
  "OP_IMAGE_TAG=latest\n"
476
407
  );
477
408
 
478
- // user.env without any token
479
- writeFileSync(
480
- join(vaultDir, "user", "user.env"),
481
- "export OP_ADMIN_TOKEN=\nexport ADMIN_TOKEN=\n"
482
- );
483
-
484
- expect(isSetupComplete(vaultDir)).toBe(false);
409
+ expect(isSetupComplete(stackDir)).toBe(false);
485
410
  });
486
411
 
487
- it("isSetupComplete falls back to true when admin token is set but OP_SETUP_COMPLETE missing", () => {
488
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
412
+ it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
413
+ mkdirSync(stateDir, { recursive: true });
489
414
  writeFileSync(
490
- join(vaultDir, "stack", "stack.env"),
491
- "OP_IMAGE_TAG=latest\nexport OP_ADMIN_TOKEN=my-real-token\n"
415
+ join(stackDir, "stack.env"),
416
+ "OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
492
417
  );
493
418
 
494
- expect(isSetupComplete(vaultDir)).toBe(true);
419
+ expect(isSetupComplete(stackDir)).toBe(true);
495
420
  });
496
421
 
497
422
  // Scenario 12: API key with special characters round-trips
@@ -512,39 +437,39 @@ describe("Broken/Corrupt State", () => {
512
437
 
513
438
  // Scenario 13: Missing stack.yml returns null
514
439
  it("readStackSpec returns null when stack.yml missing", () => {
515
- const spec = readStackSpec(configDir);
440
+ const spec = readStackSpec(stackDir);
516
441
  expect(spec).toBeNull();
517
442
  });
518
443
 
519
- // Scenario 14: config dir exists but automations dir doesn't
444
+ // Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
520
445
  it("performSetup creates missing subdirectories", async () => {
521
446
  // Seed the minimal env files first
522
447
  seedMinimalEnvFiles();
523
448
 
524
- // Remove automations dir (performSetup should recreate it)
525
- rmSync(join(configDir, "automations"), { recursive: true, force: true });
449
+ // Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
450
+ rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
526
451
 
527
452
  const result = await performSetup(
528
453
  makeValidSpec()
529
454
  );
530
455
  expect(result.ok).toBe(true);
531
456
 
532
- // Artifacts should exist in stack/ (not config/components/)
533
- expect(existsSync(join(homeDir, "stack", "core.compose.yml"))).toBe(
457
+ // Artifacts should exist in config/stack/
458
+ expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
534
459
  true
535
460
  );
536
- // Automations dir should be recreated
537
- expect(existsSync(join(configDir, "automations"))).toBe(true);
461
+ // stash/tasks dir should be recreated by ensureHomeDirs
462
+ expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
538
463
  });
539
464
 
540
465
  // Scenario 15: openpalm.yaml with old version
541
466
  it("readStackSpec returns null for version 1 spec", () => {
542
467
  writeFileSync(
543
- join(configDir, STACK_SPEC_FILENAME),
468
+ join(stackDir, STACK_SPEC_FILENAME),
544
469
  "version: 1\nconnections: []\n"
545
470
  );
546
471
 
547
- const spec = readStackSpec(configDir);
472
+ const spec = readStackSpec(stackDir);
548
473
  expect(spec).toBeNull();
549
474
  });
550
475
  });
@@ -564,15 +489,15 @@ describe("Environment Edge Cases", () => {
564
489
  rmSync(homeDir, { recursive: true, force: true });
565
490
  });
566
491
 
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 });
492
+ // Scenario 16: isSetupComplete picks up OP_UI_LOGIN_PASSWORD when set
493
+ it("isSetupComplete detects OP_UI_LOGIN_PASSWORD", () => {
494
+ mkdirSync(stateDir, { recursive: true });
570
495
  writeFileSync(
571
- join(vaultDir, "stack", "stack.env"),
572
- "SOME_OTHER_KEY=value\nexport OP_ADMIN_TOKEN=real-token-here\n"
496
+ join(stackDir, "stack.env"),
497
+ "SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
573
498
  );
574
499
 
575
- expect(isSetupComplete(vaultDir)).toBe(true);
500
+ expect(isSetupComplete(stackDir)).toBe(true);
576
501
  });
577
502
 
578
503
  // Scenario 17: export prefix on env vars
@@ -628,21 +553,11 @@ describe("Setup Input Variations", () => {
628
553
  rmSync(homeDir, { recursive: true, force: true });
629
554
  });
630
555
 
631
- // Scenario 20: Ollama in-stack setup
632
- it("Ollama in-stack setup overrides localhost URL to docker-internal", async () => {
556
+ // Scenario 20: Ollama setup
557
+ it("Ollama setup writes akm config with ollama provider", async () => {
633
558
  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
- },
559
+ llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
560
+ embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
646
561
  connections: [
647
562
  {
648
563
  id: "ollama-local",
@@ -657,23 +572,22 @@ describe("Setup Input Variations", () => {
657
572
  const result = await performSetup(input);
658
573
  expect(result.ok).toBe(true);
659
574
 
660
- // stack.yml should have ollama capabilities
661
- const spec = readStackSpec(configDir);
575
+ const spec = readStackSpec(stackDir);
662
576
  expect(spec).not.toBeNull();
663
- expect(spec!.capabilities.llm).toBe("ollama/llama3.2");
577
+ expect(spec!.version).toBe(2);
664
578
  });
665
579
 
666
580
  // Scenario 21: Multiple providers map to correct env vars
667
- it("multiple providers each write their API key to the correct env var", () => {
581
+ it("multiple providers each write their API key into auth.json keyed by providerId", () => {
668
582
  const conns: SetupConnection[] = [
669
583
  { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-openai" },
670
584
  { id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-groq" },
671
585
  { id: "anthropic-1", name: "Anthropic", provider: "anthropic", baseUrl: "", apiKey: "sk-ant-api03" },
672
586
  ];
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");
587
+ const keys = buildAuthJsonFromSetup(conns);
588
+ expect(keys.openai).toBe("sk-openai");
589
+ expect(keys.groq).toBe("gsk-groq");
590
+ expect(keys.anthropic).toBe("sk-ant-api03");
677
591
  });
678
592
 
679
593
  // Scenario 21b: OAuth providers (no API key) are silently skipped
@@ -682,19 +596,22 @@ describe("Setup Input Variations", () => {
682
596
  { id: "github-copilot", name: "GitHub Copilot", provider: "github-copilot", baseUrl: "", apiKey: "" },
683
597
  { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-test" },
684
598
  ];
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");
599
+ const keys = buildAuthJsonFromSetup(conns);
600
+ expect(keys.openai).toBe("sk-test");
601
+ expect(keys["github-copilot"]).toBeUndefined();
688
602
  });
689
603
 
690
- // Scenario 22: buildSecretsFromSetup only writes API keys and owner info
691
- it("buildSecretsFromSetup writes API keys but not config vars", () => {
604
+ // Scenario 22: buildSecretsFromSetup writes non-credential vars only;
605
+ // API keys flow into auth.json via buildAuthJsonFromSetup.
606
+ it("buildSecretsFromSetup does not write API keys; buildAuthJsonFromSetup does", () => {
692
607
  const spec = makeValidSpec();
693
608
  const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
609
+ const keys = buildAuthJsonFromSetup(spec.connections);
694
610
 
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
611
+ // API keys go to auth.json, not stack.env
612
+ expect(secrets.OPENAI_API_KEY).toBeUndefined();
613
+ expect(keys.openai).toBe("sk-test-key-123");
614
+ // Config vars (capability resolution) are not in stack.env user-secrets either
698
615
  expect(secrets.SYSTEM_LLM_PROVIDER).toBeUndefined();
699
616
  expect(secrets.SYSTEM_LLM_MODEL).toBeUndefined();
700
617
  expect(secrets.EMBEDDING_MODEL).toBeUndefined();
@@ -721,69 +638,50 @@ describe("performSetup end-to-end artifacts", () => {
721
638
  it("writes stack.yml and readStackSpec returns v2", async () => {
722
639
  await performSetup(makeValidSpec());
723
640
 
724
- const spec = readStackSpec(configDir);
641
+ const spec = readStackSpec(stackDir);
725
642
  expect(spec).not.toBeNull();
726
643
  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");
729
644
  });
730
645
 
731
- it("writes OP_CAP_EMBEDDINGS_DIMS with correct embedding dims from lookup", async () => {
646
+ it("writes akm config with embedding dims from setup spec", async () => {
732
647
  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
- },
648
+ llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
649
+ embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
745
650
  connections: [
746
- {
747
- id: "ollama-1",
748
- name: "Ollama",
749
- provider: "ollama",
750
- baseUrl: "http://localhost:11434",
751
- apiKey: "",
752
- },
651
+ { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
753
652
  ],
754
653
  });
755
654
 
756
655
  await performSetup(input);
757
656
 
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");
657
+ const akmConfigPath = join(homeDir, "config", "akm", "config.json");
658
+ const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
659
+ expect(config.embedding.dimension).toBe(768);
761
660
  });
762
661
 
763
662
  it("writes core.compose.yml to stack/", async () => {
764
663
  await performSetup(makeValidSpec());
765
664
 
766
665
  expect(
767
- existsSync(join(homeDir, "stack", "core.compose.yml"))
666
+ existsSync(join(homeDir, "config", "stack", "core.compose.yml"))
768
667
  ).toBe(true);
769
668
  });
770
669
 
771
- it("writes admin and assistant tokens to stack.env", async () => {
670
+ it("writes the UI login password to stack.env", async () => {
772
671
  await performSetup(makeValidSpec());
773
672
 
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");
673
+ const secrets = parseEnvFile(join(stackDir, "stack.env"));
674
+ expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
778
675
  });
779
676
 
780
- it("writes OP_CAP_* vars from capabilities to stack.env", async () => {
677
+ it("writes akm config with llm provider and model", async () => {
781
678
  await performSetup(makeValidSpec());
782
679
 
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");
680
+ const akmConfigPath = join(homeDir, "config", "akm", "config.json");
681
+ const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
682
+ expect(config.llm.provider).toBe("openai");
683
+ expect(config.llm.model).toBe("gpt-4o");
684
+ expect(config.embedding.model).toBe("text-embedding-3-small");
787
685
  });
788
686
  });
789
687