@openpalm/lib 0.10.1 → 0.11.0-beta.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 (55) 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 +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. 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,18 +34,8 @@ 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
- },
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" },
48
39
  security: { adminToken: "test-admin-token-12345" },
49
40
  owner: { name: "Test User", email: "test@example.com" },
50
41
  connections: [
@@ -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,16 @@ 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=",
138
+ "OP_UI_TOKEN=",
155
139
  "OP_ASSISTANT_TOKEN=",
156
- "OP_MEMORY_TOKEN=",
157
140
  "OPENAI_API_KEY=",
158
141
  "OPENAI_BASE_URL=",
159
142
  "ANTHROPIC_API_KEY=",
@@ -184,51 +167,42 @@ describe("Fresh Install", () => {
184
167
  rmSync(homeDir, { recursive: true, force: true });
185
168
  });
186
169
 
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", () => {
170
+ // Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
171
+ // does create stack.env with required keys when files do not exist.
172
+ it("ensureSecrets creates state/stack.env with required keys on fresh install", () => {
189
173
  const state: ControlPlaneState = {
190
174
  adminToken: "",
191
175
  assistantToken: "",
192
- setupToken: "",
193
176
  homeDir,
194
177
  configDir,
195
- vaultDir,
196
- dataDir,
197
- logsDir,
198
- cacheDir: join(homeDir, "cache"),
178
+ stashDir: join(homeDir, "stash"),
179
+ workspaceDir: join(homeDir, "workspace"),
180
+ cacheDir,
181
+ stateDir,
182
+ stackDir,
199
183
  services: {},
200
184
  artifacts: { compose: "" },
201
185
  artifactMeta: [],
202
186
  audit: [],
203
187
  };
204
188
 
205
- // No user.env exists yet
206
- expect(existsSync(join(vaultDir, "user", "user.env"))).toBe(false);
207
-
208
189
  ensureSecrets(state);
209
190
 
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");
191
+ // API keys and owner info are seeded in state/stack.env.
192
+ const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
216
193
  expect(stackContent).toContain("OPENAI_API_KEY=");
217
194
  expect(stackContent).toContain("OWNER_NAME=");
218
195
  });
219
196
 
220
197
  // Scenario 2: isSetupComplete returns false before setup
221
198
  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 });
199
+ mkdirSync(stateDir, { recursive: true });
224
200
  writeFileSync(
225
- join(vaultDir, "stack", "stack.env"),
201
+ join(stackDir, "stack.env"),
226
202
  "OP_SETUP_COMPLETE=false\n"
227
203
  );
228
- // Empty user.env so fallback check doesn't trigger
229
- writeFileSync(join(vaultDir, "user", "user.env"), "");
230
204
 
231
- expect(isSetupComplete(vaultDir)).toBe(false);
205
+ expect(isSetupComplete(stackDir)).toBe(false);
232
206
  });
233
207
 
234
208
  // Scenario 3: performSetup succeeds from completely empty state
@@ -242,15 +216,21 @@ describe("Fresh Install", () => {
242
216
  expect(result.ok).toBe(true);
243
217
  });
244
218
 
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 () => {
219
+ // Scenario 4: performSetup must NOT mark OP_SETUP_COMPLETE.
220
+ //
221
+ // The flag is set by setup-deploy.ts:startDeploy AFTER the Docker stack is
222
+ // confirmed healthy. If performSetup wrote it eagerly, a deploy failure
223
+ // would leave the wizard convinced setup was complete and bounce the user
224
+ // into a broken admin UI.
225
+ it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
247
226
  seedMinimalEnvFiles();
248
227
 
249
228
  await performSetup(makeValidSpec());
250
229
 
251
- const stackEnv = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
230
+ const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
252
231
  const parsed = parseEnvContent(stackEnv);
253
- expect(parsed.OP_SETUP_COMPLETE).toBe("true");
232
+ // Either entirely absent, or still the seeded "false" — never "true".
233
+ expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
254
234
  });
255
235
  });
256
236
 
@@ -270,23 +250,21 @@ describe("Existing Install", () => {
270
250
  rmSync(homeDir, { recursive: true, force: true });
271
251
  });
272
252
 
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);
253
+ // Scenario 5: ensureSecrets does NOT overwrite existing stack.env
254
+ it("ensureSecrets does not overwrite existing stack.env tokens", () => {
255
+ mkdirSync(stateDir, { recursive: true });
256
+ writeFileSync(join(stackDir, "stack.env"), "OP_UI_TOKEN=my-custom-token\nOP_ASSISTANT_TOKEN=existing-token\n");
279
257
 
280
258
  const state: ControlPlaneState = {
281
259
  adminToken: "",
282
260
  assistantToken: "",
283
- setupToken: "",
284
261
  homeDir,
285
262
  configDir,
286
- vaultDir,
287
- dataDir,
288
- logsDir,
289
- cacheDir: join(homeDir, "cache"),
263
+ stashDir: join(homeDir, "stash"),
264
+ workspaceDir: join(homeDir, "workspace"),
265
+ cacheDir,
266
+ stateDir,
267
+ stackDir,
290
268
  services: {},
291
269
  artifacts: { compose: "" },
292
270
  artifactMeta: [],
@@ -295,21 +273,23 @@ describe("Existing Install", () => {
295
273
 
296
274
  ensureSecrets(state);
297
275
 
298
- const afterContent = readFileSync(join(vaultDir, "user", "user.env"), "utf-8");
299
- expect(afterContent).toBe(customContent);
276
+ // Existing tokens must be preserved
277
+ const afterContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
278
+ expect(afterContent).toContain("OP_UI_TOKEN=my-custom-token");
279
+ expect(afterContent).toContain("OP_ASSISTANT_TOKEN=existing-token");
300
280
  });
301
281
 
302
- // Scenario 6: performSetup re-run preserves OP_MEMORY_TOKEN
303
- it("performSetup re-run preserves OP_MEMORY_TOKEN from first run", async () => {
282
+ // Scenario 6: performSetup re-run preserves OP_ASSISTANT_TOKEN
283
+ it("performSetup re-run preserves OP_ASSISTANT_TOKEN from first run", async () => {
304
284
  // First setup
305
285
  await performSetup(makeValidSpec());
306
286
 
307
287
  const secretsAfterFirst = readFileSync(
308
- join(vaultDir, "stack", "stack.env"),
288
+ join(stackDir, "stack.env"),
309
289
  "utf-8"
310
290
  );
311
291
  const firstMatch = secretsAfterFirst.match(
312
- /OP_MEMORY_TOKEN=([a-f0-9]+)/
292
+ /OP_ASSISTANT_TOKEN=([a-f0-9]+)/
313
293
  );
314
294
  expect(firstMatch).not.toBeNull();
315
295
  const firstToken = firstMatch![1];
@@ -330,53 +310,41 @@ describe("Existing Install", () => {
330
310
  );
331
311
 
332
312
  const secretsAfterSecond = readFileSync(
333
- join(vaultDir, "stack", "stack.env"),
313
+ join(stackDir, "stack.env"),
334
314
  "utf-8"
335
315
  );
336
316
  const secondMatch = secretsAfterSecond.match(
337
- /OP_MEMORY_TOKEN=([a-f0-9]+)/
317
+ /OP_ASSISTANT_TOKEN=([a-f0-9]+)/
338
318
  );
339
319
  expect(secondMatch).not.toBeNull();
340
- // OP_MEMORY_TOKEN should be preserved (buildSystemSecretsFromSetup does not overwrite it)
320
+ // OP_ASSISTANT_TOKEN should be preserved across setups
341
321
  expect(secondMatch![1]).toBe(firstToken);
342
322
  });
343
323
 
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 () => {
324
+ // Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE see scenario
325
+ // 4 in the Fresh Install block for the rationale. The deploy phase owns
326
+ // this flag and only writes it after the container stack is healthy.
327
+ it("performSetup does NOT mark OP_SETUP_COMPLETE (deploy owns that flag)", async () => {
346
328
  await performSetup(makeValidSpec());
347
329
 
348
330
  const stackEnv = readFileSync(
349
- join(vaultDir, "stack", "stack.env"),
331
+ join(stackDir, "stack.env"),
350
332
  "utf-8"
351
333
  );
352
334
  const parsed = parseEnvContent(stackEnv);
353
- expect(parsed.OP_SETUP_COMPLETE).toBe("true");
335
+ expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
354
336
  });
355
337
 
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 () => {
338
+ // Scenario 8: Re-setup with different provider updates akm config
339
+ it("re-setup with different provider updates akm config", async () => {
358
340
  // First setup with OpenAI
359
341
  await performSetup(makeValidSpec());
360
342
 
361
- const specAfterFirst = readStackSpec(configDir);
362
- expect(specAfterFirst).not.toBeNull();
363
- expect(specAfterFirst!.capabilities.llm).toContain("openai/");
364
-
365
343
  // Second setup with Groq
366
344
  await performSetup(
367
345
  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
- },
346
+ llm: { provider: "groq", model: "llama3-70b-8192", baseUrl: "https://api.groq.com/openai/v1" },
347
+ embedding: { provider: "groq", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.groq.com/openai/v1" },
380
348
  connections: [
381
349
  {
382
350
  id: "groq-main",
@@ -389,12 +357,13 @@ describe("Existing Install", () => {
389
357
  })
390
358
  );
391
359
 
392
- const specAfterSecond = readStackSpec(configDir);
360
+ // stack.yml is just a version marker now
361
+ const specAfterSecond = readStackSpec(stackDir);
393
362
  expect(specAfterSecond).not.toBeNull();
394
- expect(specAfterSecond!.capabilities.llm).toBe("groq/llama3-70b-8192");
363
+ expect(specAfterSecond!.version).toBe(2);
395
364
 
396
365
  // stack.env should retain both keys
397
- const secrets = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
366
+ const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
398
367
  expect(secrets).toContain("GROQ_API_KEY");
399
368
  });
400
369
  });
@@ -414,21 +383,21 @@ describe("Broken/Corrupt State", () => {
414
383
  rmSync(homeDir, { recursive: true, force: true });
415
384
  });
416
385
 
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"), "");
386
+ // Scenario 9: ensureSecrets is idempotent on repeated calls
387
+ it("ensureSecrets is idempotent second call does not overwrite existing stack.env", () => {
388
+ mkdirSync(stateDir, { recursive: true });
389
+ writeFileSync(join(stackDir, "stack.env"), "OP_UI_TOKEN=existing-token\nOP_ASSISTANT_TOKEN=existing-assistant\n");
421
390
 
422
391
  const state: ControlPlaneState = {
423
392
  adminToken: "",
424
393
  assistantToken: "",
425
- setupToken: "",
426
394
  homeDir,
427
395
  configDir,
428
- vaultDir,
429
- dataDir,
430
- logsDir,
431
- cacheDir: join(homeDir, "cache"),
396
+ stashDir: join(homeDir, "stash"),
397
+ workspaceDir: join(homeDir, "workspace"),
398
+ cacheDir,
399
+ stateDir,
400
+ stackDir,
432
401
  services: {},
433
402
  artifacts: { compose: "" },
434
403
  artifactMeta: [],
@@ -437,12 +406,13 @@ describe("Broken/Corrupt State", () => {
437
406
 
438
407
  ensureSecrets(state);
439
408
 
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("");
409
+ // Existing tokens must be preserved
410
+ const content = readFileSync(join(stackDir, "stack.env"), "utf-8");
411
+ expect(content).toContain("OP_UI_TOKEN=existing-token");
412
+ expect(content).toContain("OP_ASSISTANT_TOKEN=existing-assistant");
443
413
  });
444
414
 
445
- // Scenario 10: user.env with malformed lines
415
+ // Scenario 10: env file with malformed lines
446
416
  it("parseEnvFile handles malformed env lines gracefully", () => {
447
417
  const malformedContent = [
448
418
  "# Comment line",
@@ -456,10 +426,10 @@ describe("Broken/Corrupt State", () => {
456
426
  " # indented comment",
457
427
  ].join("\n");
458
428
 
459
- mkdirSync(join(vaultDir, "user"), { recursive: true });
460
- writeFileSync(join(vaultDir, "user", "user.env"), malformedContent);
429
+ mkdirSync(stateDir, { recursive: true });
430
+ writeFileSync(join(stateDir, "test.env"), malformedContent);
461
431
 
462
- const parsed = parseEnvFile(join(vaultDir, "user", "user.env"));
432
+ const parsed = parseEnvFile(join(stateDir, "test.env"));
463
433
  expect(parsed.VALID_KEY).toBe("valid_value");
464
434
  expect(parsed.EXPORTED_KEY).toBe("exported_value");
465
435
  expect(parsed.ANOTHER_VALID).toBe("value");
@@ -468,30 +438,23 @@ describe("Broken/Corrupt State", () => {
468
438
  // Scenario 11: stack.env missing OP_SETUP_COMPLETE
469
439
  it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
470
440
  // stack.env without OP_SETUP_COMPLETE
471
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
472
- mkdirSync(join(vaultDir, "user"), { recursive: true });
441
+ mkdirSync(stateDir, { recursive: true });
473
442
  writeFileSync(
474
- join(vaultDir, "stack", "stack.env"),
443
+ join(stackDir, "stack.env"),
475
444
  "OP_IMAGE_TAG=latest\n"
476
445
  );
477
446
 
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);
447
+ expect(isSetupComplete(stackDir)).toBe(false);
485
448
  });
486
449
 
487
450
  it("isSetupComplete falls back to true when admin token is set but OP_SETUP_COMPLETE missing", () => {
488
- mkdirSync(join(vaultDir, "stack"), { recursive: true });
451
+ mkdirSync(stateDir, { recursive: true });
489
452
  writeFileSync(
490
- join(vaultDir, "stack", "stack.env"),
491
- "OP_IMAGE_TAG=latest\nexport OP_ADMIN_TOKEN=my-real-token\n"
453
+ join(stackDir, "stack.env"),
454
+ "OP_IMAGE_TAG=latest\nexport OP_UI_TOKEN=my-real-token\n"
492
455
  );
493
456
 
494
- expect(isSetupComplete(vaultDir)).toBe(true);
457
+ expect(isSetupComplete(stackDir)).toBe(true);
495
458
  });
496
459
 
497
460
  // Scenario 12: API key with special characters round-trips
@@ -512,39 +475,39 @@ describe("Broken/Corrupt State", () => {
512
475
 
513
476
  // Scenario 13: Missing stack.yml returns null
514
477
  it("readStackSpec returns null when stack.yml missing", () => {
515
- const spec = readStackSpec(configDir);
478
+ const spec = readStackSpec(stackDir);
516
479
  expect(spec).toBeNull();
517
480
  });
518
481
 
519
- // Scenario 14: config dir exists but automations dir doesn't
482
+ // Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
520
483
  it("performSetup creates missing subdirectories", async () => {
521
484
  // Seed the minimal env files first
522
485
  seedMinimalEnvFiles();
523
486
 
524
- // Remove automations dir (performSetup should recreate it)
525
- rmSync(join(configDir, "automations"), { recursive: true, force: true });
487
+ // Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
488
+ rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
526
489
 
527
490
  const result = await performSetup(
528
491
  makeValidSpec()
529
492
  );
530
493
  expect(result.ok).toBe(true);
531
494
 
532
- // Artifacts should exist in stack/ (not config/components/)
533
- expect(existsSync(join(homeDir, "stack", "core.compose.yml"))).toBe(
495
+ // Artifacts should exist in config/stack/
496
+ expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
534
497
  true
535
498
  );
536
- // Automations dir should be recreated
537
- expect(existsSync(join(configDir, "automations"))).toBe(true);
499
+ // stash/tasks dir should be recreated by ensureHomeDirs
500
+ expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
538
501
  });
539
502
 
540
503
  // Scenario 15: openpalm.yaml with old version
541
504
  it("readStackSpec returns null for version 1 spec", () => {
542
505
  writeFileSync(
543
- join(configDir, STACK_SPEC_FILENAME),
506
+ join(stackDir, STACK_SPEC_FILENAME),
544
507
  "version: 1\nconnections: []\n"
545
508
  );
546
509
 
547
- const spec = readStackSpec(configDir);
510
+ const spec = readStackSpec(stackDir);
548
511
  expect(spec).toBeNull();
549
512
  });
550
513
  });
@@ -564,15 +527,15 @@ describe("Environment Edge Cases", () => {
564
527
  rmSync(homeDir, { recursive: true, force: true });
565
528
  });
566
529
 
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 });
530
+ // Scenario 16: Commented-out ADMIN_TOKEN but OP_UI_TOKEN set
531
+ it("isSetupComplete detects OP_UI_TOKEN when ADMIN_TOKEN is commented out", () => {
532
+ mkdirSync(stateDir, { recursive: true });
570
533
  writeFileSync(
571
- join(vaultDir, "stack", "stack.env"),
572
- "SOME_OTHER_KEY=value\nexport OP_ADMIN_TOKEN=real-token-here\n"
534
+ join(stackDir, "stack.env"),
535
+ "SOME_OTHER_KEY=value\nexport OP_UI_TOKEN=real-token-here\n"
573
536
  );
574
537
 
575
- expect(isSetupComplete(vaultDir)).toBe(true);
538
+ expect(isSetupComplete(stackDir)).toBe(true);
576
539
  });
577
540
 
578
541
  // Scenario 17: export prefix on env vars
@@ -628,21 +591,11 @@ describe("Setup Input Variations", () => {
628
591
  rmSync(homeDir, { recursive: true, force: true });
629
592
  });
630
593
 
631
- // Scenario 20: Ollama in-stack setup
632
- it("Ollama in-stack setup overrides localhost URL to docker-internal", async () => {
594
+ // Scenario 20: Ollama setup
595
+ it("Ollama setup writes akm config with ollama provider", async () => {
633
596
  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
- },
597
+ llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
598
+ embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
646
599
  connections: [
647
600
  {
648
601
  id: "ollama-local",
@@ -657,23 +610,22 @@ describe("Setup Input Variations", () => {
657
610
  const result = await performSetup(input);
658
611
  expect(result.ok).toBe(true);
659
612
 
660
- // stack.yml should have ollama capabilities
661
- const spec = readStackSpec(configDir);
613
+ const spec = readStackSpec(stackDir);
662
614
  expect(spec).not.toBeNull();
663
- expect(spec!.capabilities.llm).toBe("ollama/llama3.2");
615
+ expect(spec!.version).toBe(2);
664
616
  });
665
617
 
666
618
  // Scenario 21: Multiple providers map to correct env vars
667
- it("multiple providers each write their API key to the correct env var", () => {
619
+ it("multiple providers each write their API key into auth.json keyed by providerId", () => {
668
620
  const conns: SetupConnection[] = [
669
621
  { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-openai" },
670
622
  { id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-groq" },
671
623
  { id: "anthropic-1", name: "Anthropic", provider: "anthropic", baseUrl: "", apiKey: "sk-ant-api03" },
672
624
  ];
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");
625
+ const keys = buildAuthJsonFromSetup(conns);
626
+ expect(keys.openai).toBe("sk-openai");
627
+ expect(keys.groq).toBe("gsk-groq");
628
+ expect(keys.anthropic).toBe("sk-ant-api03");
677
629
  });
678
630
 
679
631
  // Scenario 21b: OAuth providers (no API key) are silently skipped
@@ -682,19 +634,22 @@ describe("Setup Input Variations", () => {
682
634
  { id: "github-copilot", name: "GitHub Copilot", provider: "github-copilot", baseUrl: "", apiKey: "" },
683
635
  { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-test" },
684
636
  ];
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");
637
+ const keys = buildAuthJsonFromSetup(conns);
638
+ expect(keys.openai).toBe("sk-test");
639
+ expect(keys["github-copilot"]).toBeUndefined();
688
640
  });
689
641
 
690
- // Scenario 22: buildSecretsFromSetup only writes API keys and owner info
691
- it("buildSecretsFromSetup writes API keys but not config vars", () => {
642
+ // Scenario 22: buildSecretsFromSetup writes non-credential vars only;
643
+ // API keys flow into auth.json via buildAuthJsonFromSetup.
644
+ it("buildSecretsFromSetup does not write API keys; buildAuthJsonFromSetup does", () => {
692
645
  const spec = makeValidSpec();
693
646
  const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
647
+ const keys = buildAuthJsonFromSetup(spec.connections);
694
648
 
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
649
+ // API keys go to auth.json, not stack.env
650
+ expect(secrets.OPENAI_API_KEY).toBeUndefined();
651
+ expect(keys.openai).toBe("sk-test-key-123");
652
+ // Config vars (capability resolution) are not in stack.env user-secrets either
698
653
  expect(secrets.SYSTEM_LLM_PROVIDER).toBeUndefined();
699
654
  expect(secrets.SYSTEM_LLM_MODEL).toBeUndefined();
700
655
  expect(secrets.EMBEDDING_MODEL).toBeUndefined();
@@ -721,69 +676,52 @@ describe("performSetup end-to-end artifacts", () => {
721
676
  it("writes stack.yml and readStackSpec returns v2", async () => {
722
677
  await performSetup(makeValidSpec());
723
678
 
724
- const spec = readStackSpec(configDir);
679
+ const spec = readStackSpec(stackDir);
725
680
  expect(spec).not.toBeNull();
726
681
  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
682
  });
730
683
 
731
- it("writes OP_CAP_EMBEDDINGS_DIMS with correct embedding dims from lookup", async () => {
684
+ it("writes akm config with embedding dims from setup spec", async () => {
732
685
  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
- },
686
+ llm: { provider: "ollama", model: "llama3.2", baseUrl: "http://localhost:11434/v1" },
687
+ embedding: { provider: "ollama", model: "nomic-embed-text", dims: 768, baseUrl: "http://localhost:11434/v1" },
745
688
  connections: [
746
- {
747
- id: "ollama-1",
748
- name: "Ollama",
749
- provider: "ollama",
750
- baseUrl: "http://localhost:11434",
751
- apiKey: "",
752
- },
689
+ { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
753
690
  ],
754
691
  });
755
692
 
756
693
  await performSetup(input);
757
694
 
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");
695
+ const akmConfigPath = join(homeDir, "config", "akm", "config.json");
696
+ const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
697
+ expect(config.embedding.dimension).toBe(768);
761
698
  });
762
699
 
763
700
  it("writes core.compose.yml to stack/", async () => {
764
701
  await performSetup(makeValidSpec());
765
702
 
766
703
  expect(
767
- existsSync(join(homeDir, "stack", "core.compose.yml"))
704
+ existsSync(join(homeDir, "config", "stack", "core.compose.yml"))
768
705
  ).toBe(true);
769
706
  });
770
707
 
771
708
  it("writes admin and assistant tokens to stack.env", async () => {
772
709
  await performSetup(makeValidSpec());
773
710
 
774
- const secrets = parseEnvFile(join(vaultDir, "stack", "stack.env"));
775
- expect(secrets.OP_ADMIN_TOKEN).toBe("test-admin-token-12345");
711
+ const secrets = parseEnvFile(join(stackDir, "stack.env"));
712
+ expect(secrets.OP_UI_TOKEN).toBe("test-admin-token-12345");
776
713
  expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
777
714
  expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
778
715
  });
779
716
 
780
- it("writes OP_CAP_* vars from capabilities to stack.env", async () => {
717
+ it("writes akm config with llm provider and model", async () => {
781
718
  await performSetup(makeValidSpec());
782
719
 
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");
720
+ const akmConfigPath = join(homeDir, "config", "akm", "config.json");
721
+ const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
722
+ expect(config.llm.provider).toBe("openai");
723
+ expect(config.llm.model).toBe("gpt-4o");
724
+ expect(config.embedding.model).toBe("text-embedding-3-small");
787
725
  });
788
726
  });
789
727