@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

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 (66) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -2,7 +2,7 @@
2
2
  * Edge-case tests for the OpenPalm install and setup flow.
3
3
  *
4
4
  * Each test creates its own temp directory tree mimicking the single
5
- * ~/.openpalm/ root layout (config, vault, data, logs), then runs the
5
+ * ~/.openpalm/ root layout (config, knowledge, data, logs), then runs the
6
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";
@@ -23,11 +23,10 @@ import {
23
23
  performSetup,
24
24
  buildSecretsFromSetup,
25
25
  buildAuthJsonFromSetup,
26
- buildSystemSecretsFromSetup,
27
26
  } from "./setup.js";
28
27
  import type { SetupSpec, SetupConnection } from "./setup.js";
29
28
  import type { ControlPlaneState } from "./types.js";
30
- import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
29
+ import { readSecret } from './secrets-files.js';
31
30
 
32
31
  // ── Helpers ──────────────────────────────────────────────────────────────
33
32
 
@@ -55,70 +54,69 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
55
54
  function seedRequiredAssets(homeDir: string): void {
56
55
  mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
57
56
  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");
57
+ mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
58
+ writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
59
+ writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
60
+ mkdirSync(join(homeDir, "data"), { recursive: true });
61
+ // Automations live in knowledge/tasks as AKM-owned task files.
62
+ mkdirSync(join(homeDir, "knowledge", "tasks"), { recursive: true });
63
+ writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
64
+ writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
65
+ writeFileSync(join(homeDir, "knowledge", "tasks", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
67
66
  }
68
67
 
69
68
  // ── Shared test fixture ──────────────────────────────────────────────────
70
69
 
71
70
  let homeDir: string;
72
71
  let configDir: string;
73
- let stateDir: string;
72
+ let dataDir: string;
74
73
  let stackDir: string;
75
- let cacheDir: string;
76
74
 
77
75
  const savedEnv: Record<string, string | undefined> = {};
78
76
 
79
77
  function saveAndSetEnv(): void {
80
78
  savedEnv.OP_HOME = process.env.OP_HOME;
79
+ savedEnv.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
80
+ savedEnv.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
81
81
  process.env.OP_HOME = homeDir;
82
+ delete process.env.OP_UI_LOGIN_PASSWORD;
83
+ delete process.env.OP_OPENCODE_PASSWORD;
82
84
  }
83
85
 
84
86
  function restoreEnv(): void {
85
- process.env.OP_HOME = savedEnv.OP_HOME;
87
+ if (savedEnv.OP_HOME === undefined) delete process.env.OP_HOME;
88
+ else process.env.OP_HOME = savedEnv.OP_HOME;
89
+ if (savedEnv.OP_UI_LOGIN_PASSWORD === undefined) delete process.env.OP_UI_LOGIN_PASSWORD;
90
+ else process.env.OP_UI_LOGIN_PASSWORD = savedEnv.OP_UI_LOGIN_PASSWORD;
91
+ if (savedEnv.OP_OPENCODE_PASSWORD === undefined) delete process.env.OP_OPENCODE_PASSWORD;
92
+ else process.env.OP_OPENCODE_PASSWORD = savedEnv.OP_OPENCODE_PASSWORD;
86
93
  }
87
94
 
88
95
  /** Create a full directory tree matching ensureHomeDirs() output. */
89
96
  function createFullDirTree(): void {
90
97
  homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
91
98
  configDir = join(homeDir, "config");
92
- stateDir = join(homeDir, "state");
99
+ dataDir = join(homeDir, "data");
93
100
  stackDir = join(configDir, "stack");
94
- cacheDir = join(homeDir, "cache");
95
101
 
96
102
  for (const dir of [
97
103
  homeDir,
98
104
  configDir,
99
- join(homeDir, "state", "registry", "automations"),
100
105
  join(configDir, "assistant"),
101
106
  join(configDir, "akm"),
102
- join(homeDir, "stash"),
107
+ join(homeDir, "knowledge"),
108
+ join(homeDir, "knowledge", "env"),
109
+ join(homeDir, "knowledge", "secrets"),
103
110
  join(homeDir, "workspace"),
104
111
  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"),
112
+ dataDir,
113
+ join(dataDir, "assistant"),
114
+ join(dataDir, "guardian"),
115
+ join(dataDir, "akm", "cache"),
116
+ join(dataDir, "akm", "data"),
117
+ join(dataDir, "logs"),
118
+ join(dataDir, "backups"),
119
+ join(dataDir, "rollback"),
122
120
  ]) {
123
121
  mkdirSync(dir, { recursive: true });
124
122
  }
@@ -132,16 +130,10 @@ function seedMinimalEnvFiles(): void {
132
130
  mkdirSync(stackDir, { recursive: true });
133
131
 
134
132
  writeFileSync(
135
- join(stackDir, "stack.env"),
133
+ join(homeDir, "knowledge", "env", "stack.env"),
136
134
  [
137
135
  "# OpenPalm — Stack Configuration",
138
- "OP_UI_LOGIN_PASSWORD=",
139
- "OPENAI_API_KEY=",
140
136
  "OPENAI_BASE_URL=",
141
- "ANTHROPIC_API_KEY=",
142
- "GROQ_API_KEY=",
143
- "MISTRAL_API_KEY=",
144
- "GOOGLE_API_KEY=",
145
137
  "OP_OWNER_NAME=",
146
138
  "OP_OWNER_EMAIL=",
147
139
  "",
@@ -166,16 +158,15 @@ describe("Fresh Install", () => {
166
158
  rmSync(homeDir, { recursive: true, force: true });
167
159
  });
168
160
 
169
- // Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
161
+ // Scenario 1: ensureSecrets does NOT seed user.env (see akm-user-env) but
170
162
  // 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", () => {
163
+ it("ensureSecrets creates stack.env with required keys on fresh install", () => {
172
164
  const state: ControlPlaneState = {
173
165
  homeDir,
174
166
  configDir,
175
- stashDir: join(homeDir, "stash"),
167
+ stashDir: join(homeDir, "knowledge"),
176
168
  workspaceDir: join(homeDir, "workspace"),
177
- cacheDir,
178
- stateDir,
169
+ dataDir,
179
170
  stackDir,
180
171
  services: {},
181
172
  artifacts: { compose: "" },
@@ -184,17 +175,18 @@ describe("Fresh Install", () => {
184
175
 
185
176
  ensureSecrets(state);
186
177
 
187
- // API keys and owner info are seeded in state/stack.env.
188
- const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
189
- expect(stackContent).toContain("OPENAI_API_KEY=");
190
- expect(stackContent).toContain("OP_OWNER_NAME=");
178
+ // stack.env only carries non-secret setup/config keys.
179
+ const stackContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
180
+ expect(stackContent).not.toContain("OPENAI_API_KEY=");
181
+ expect(stackContent).toContain("OP_SETUP_COMPLETE=false");
182
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
191
183
  });
192
184
 
193
185
  // Scenario 2: isSetupComplete returns false before setup
194
186
  it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
195
- mkdirSync(stateDir, { recursive: true });
187
+ mkdirSync(dataDir, { recursive: true });
196
188
  writeFileSync(
197
- join(stackDir, "stack.env"),
189
+ join(homeDir, "knowledge", "env", "stack.env"),
198
190
  "OP_SETUP_COMPLETE=false\n"
199
191
  );
200
192
 
@@ -223,7 +215,7 @@ describe("Fresh Install", () => {
223
215
 
224
216
  await performSetup(makeValidSpec());
225
217
 
226
- const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
218
+ const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
227
219
  const parsed = parseEnvContent(stackEnv);
228
220
  // Either entirely absent, or still the seeded "false" — never "true".
229
221
  expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
@@ -246,18 +238,17 @@ describe("Existing Install", () => {
246
238
  rmSync(homeDir, { recursive: true, force: true });
247
239
  });
248
240
 
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");
241
+ // Scenario 5: ensureSecrets creates file-based secrets without stack.env tokens
242
+ it("ensureSecrets creates file-based system secrets", () => {
243
+ mkdirSync(dataDir, { recursive: true });
244
+ writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
253
245
 
254
246
  const state: ControlPlaneState = {
255
247
  homeDir,
256
248
  configDir,
257
- stashDir: join(homeDir, "stash"),
249
+ stashDir: join(homeDir, "knowledge"),
258
250
  workspaceDir: join(homeDir, "workspace"),
259
- cacheDir,
260
- stateDir,
251
+ dataDir,
261
252
  stackDir,
262
253
  services: {},
263
254
  artifacts: { compose: "" },
@@ -266,25 +257,23 @@ describe("Existing Install", () => {
266
257
 
267
258
  ensureSecrets(state);
268
259
 
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");
260
+ const afterContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
261
+ expect(afterContent).not.toContain("OP_UI_LOGIN_PASSWORD=");
262
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
272
263
  });
273
264
 
274
265
  // Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
275
266
  // operator supplies a new one in the spec. This is intentional — the
276
267
  // wizard "rerun" path is how an operator rotates the password. The
277
268
  // 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 () => {
269
+ it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD secret file when spec changes", async () => {
279
270
  await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
280
271
 
281
- const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
282
- expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
272
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("first-password-12345\n");
283
273
 
284
274
  await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
285
275
 
286
- const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
287
- expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
276
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("second-password-12345\n");
288
277
  });
289
278
 
290
279
  // Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
@@ -294,7 +283,7 @@ describe("Existing Install", () => {
294
283
  await performSetup(makeValidSpec());
295
284
 
296
285
  const stackEnv = readFileSync(
297
- join(stackDir, "stack.env"),
286
+ join(homeDir, "knowledge", "env", "stack.env"),
298
287
  "utf-8"
299
288
  );
300
289
  const parsed = parseEnvContent(stackEnv);
@@ -323,14 +312,8 @@ describe("Existing Install", () => {
323
312
  })
324
313
  );
325
314
 
326
- // stack.yml is just a version marker now
327
- const specAfterSecond = readStackSpec(stackDir);
328
- expect(specAfterSecond).not.toBeNull();
329
- expect(specAfterSecond!.version).toBe(2);
330
-
331
- // stack.env should retain both keys
332
- const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
333
- expect(secrets).toContain("GROQ_API_KEY");
315
+ const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
316
+ expect(auth.groq.key).toBe("gsk-test-key-456");
334
317
  });
335
318
  });
336
319
 
@@ -351,16 +334,15 @@ describe("Broken/Corrupt State", () => {
351
334
 
352
335
  // Scenario 9: ensureSecrets is idempotent on repeated calls
353
336
  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");
337
+ mkdirSync(dataDir, { recursive: true });
338
+ writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
356
339
 
357
340
  const state: ControlPlaneState = {
358
341
  homeDir,
359
342
  configDir,
360
- stashDir: join(homeDir, "stash"),
343
+ stashDir: join(homeDir, "knowledge"),
361
344
  workspaceDir: join(homeDir, "workspace"),
362
- cacheDir,
363
- stateDir,
345
+ dataDir,
364
346
  stackDir,
365
347
  services: {},
366
348
  artifacts: { compose: "" },
@@ -369,9 +351,10 @@ describe("Broken/Corrupt State", () => {
369
351
 
370
352
  ensureSecrets(state);
371
353
 
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");
354
+ // Existing non-secret stack config must be preserved.
355
+ const content = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
356
+ expect(content).toContain("OP_SETUP_COMPLETE=false");
357
+ expect(content).not.toContain("OP_UI_LOGIN_PASSWORD=");
375
358
  });
376
359
 
377
360
  // Scenario 10: env file with malformed lines
@@ -388,10 +371,10 @@ describe("Broken/Corrupt State", () => {
388
371
  " # indented comment",
389
372
  ].join("\n");
390
373
 
391
- mkdirSync(stateDir, { recursive: true });
392
- writeFileSync(join(stateDir, "test.env"), malformedContent);
374
+ mkdirSync(dataDir, { recursive: true });
375
+ writeFileSync(join(dataDir, "test.env"), malformedContent);
393
376
 
394
- const parsed = parseEnvFile(join(stateDir, "test.env"));
377
+ const parsed = parseEnvFile(join(dataDir, "test.env"));
395
378
  expect(parsed.VALID_KEY).toBe("valid_value");
396
379
  expect(parsed.EXPORTED_KEY).toBe("exported_value");
397
380
  expect(parsed.ANOTHER_VALID).toBe("value");
@@ -400,23 +383,25 @@ describe("Broken/Corrupt State", () => {
400
383
  // Scenario 11: stack.env missing OP_SETUP_COMPLETE
401
384
  it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
402
385
  // stack.env without OP_SETUP_COMPLETE
403
- mkdirSync(stateDir, { recursive: true });
386
+ mkdirSync(dataDir, { recursive: true });
404
387
  writeFileSync(
405
- join(stackDir, "stack.env"),
388
+ join(homeDir, "knowledge", "env", "stack.env"),
406
389
  "OP_IMAGE_TAG=latest\n"
407
390
  );
408
391
 
409
392
  expect(isSetupComplete(stackDir)).toBe(false);
410
393
  });
411
394
 
412
- it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
413
- mkdirSync(stateDir, { recursive: true });
395
+ it("isSetupComplete returns false when OP_UI_LOGIN_PASSWORD is set but OP_SETUP_COMPLETE is missing", () => {
396
+ mkdirSync(dataDir, { recursive: true });
414
397
  writeFileSync(
415
- join(stackDir, "stack.env"),
398
+ join(homeDir, "knowledge", "env", "stack.env"),
416
399
  "OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
417
400
  );
418
401
 
419
- expect(isSetupComplete(stackDir)).toBe(true);
402
+ // Password alone is no longer a proxy for setup completion.
403
+ // Only OP_SETUP_COMPLETE=true counts.
404
+ expect(isSetupComplete(stackDir)).toBe(false);
420
405
  });
421
406
 
422
407
  // Scenario 12: API key with special characters round-trips
@@ -435,19 +420,13 @@ describe("Broken/Corrupt State", () => {
435
420
  }
436
421
  });
437
422
 
438
- // Scenario 13: Missing stack.yml returns null
439
- it("readStackSpec returns null when stack.yml missing", () => {
440
- const spec = readStackSpec(stackDir);
441
- expect(spec).toBeNull();
442
- });
443
-
444
- // Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
423
+ // Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
445
424
  it("performSetup creates missing subdirectories", async () => {
446
425
  // Seed the minimal env files first
447
426
  seedMinimalEnvFiles();
448
427
 
449
- // Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
450
- rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
428
+ // Remove knowledge/tasks dir (performSetup should recreate it via ensureHomeDirs)
429
+ rmSync(join(homeDir, "knowledge", "tasks"), { recursive: true, force: true });
451
430
 
452
431
  const result = await performSetup(
453
432
  makeValidSpec()
@@ -458,20 +437,10 @@ describe("Broken/Corrupt State", () => {
458
437
  expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
459
438
  true
460
439
  );
461
- // stash/tasks dir should be recreated by ensureHomeDirs
462
- expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
440
+ // knowledge/tasks dir should be recreated by ensureHomeDirs
441
+ expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
463
442
  });
464
443
 
465
- // Scenario 15: openpalm.yaml with old version
466
- it("readStackSpec returns null for version 1 spec", () => {
467
- writeFileSync(
468
- join(stackDir, STACK_SPEC_FILENAME),
469
- "version: 1\nconnections: []\n"
470
- );
471
-
472
- const spec = readStackSpec(stackDir);
473
- expect(spec).toBeNull();
474
- });
475
444
  });
476
445
 
477
446
  // =====================================================================
@@ -489,15 +458,15 @@ describe("Environment Edge Cases", () => {
489
458
  rmSync(homeDir, { recursive: true, force: true });
490
459
  });
491
460
 
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 });
461
+ // Scenario 16: isSetupComplete requires explicit OP_SETUP_COMPLETE=true
462
+ it("isSetupComplete returns false when only OP_UI_LOGIN_PASSWORD is set", () => {
463
+ mkdirSync(dataDir, { recursive: true });
495
464
  writeFileSync(
496
- join(stackDir, "stack.env"),
465
+ join(homeDir, "knowledge", "env", "stack.env"),
497
466
  "SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
498
467
  );
499
468
 
500
- expect(isSetupComplete(stackDir)).toBe(true);
469
+ expect(isSetupComplete(stackDir)).toBe(false);
501
470
  });
502
471
 
503
472
  // Scenario 17: export prefix on env vars
@@ -571,10 +540,6 @@ describe("Setup Input Variations", () => {
571
540
 
572
541
  const result = await performSetup(input);
573
542
  expect(result.ok).toBe(true);
574
-
575
- const spec = readStackSpec(stackDir);
576
- expect(spec).not.toBeNull();
577
- expect(spec!.version).toBe(2);
578
543
  });
579
544
 
580
545
  // Scenario 21: Multiple providers map to correct env vars
@@ -635,12 +600,9 @@ describe("performSetup end-to-end artifacts", () => {
635
600
  rmSync(homeDir, { recursive: true, force: true });
636
601
  });
637
602
 
638
- it("writes stack.yml and readStackSpec returns v2", async () => {
603
+ it("does not create a stack.yml (addon state lives in stack.env)", async () => {
639
604
  await performSetup(makeValidSpec());
640
-
641
- const spec = readStackSpec(stackDir);
642
- expect(spec).not.toBeNull();
643
- expect(spec!.version).toBe(2);
605
+ expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
644
606
  });
645
607
 
646
608
  it("writes akm config with embedding dims from setup spec", async () => {
@@ -667,11 +629,10 @@ describe("performSetup end-to-end artifacts", () => {
667
629
  ).toBe(true);
668
630
  });
669
631
 
670
- it("writes the UI login password to stack.env", async () => {
632
+ it("writes the UI login password to a secret file", async () => {
671
633
  await performSetup(makeValidSpec());
672
634
 
673
- const secrets = parseEnvFile(join(stackDir, "stack.env"));
674
- expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
635
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
675
636
  });
676
637
 
677
638
  it("writes akm config with llm provider and model", async () => {
@@ -679,8 +640,11 @@ describe("performSetup end-to-end artifacts", () => {
679
640
 
680
641
  const akmConfigPath = join(homeDir, "config", "akm", "config.json");
681
642
  const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
682
- expect(config.llm.provider).toBe("openai");
683
- expect(config.llm.model).toBe("gpt-4o");
643
+ // Canonical akm 0.8.0 shape (I-3): profiles.llm.default + defaults.llm.
644
+ expect(config.llm).toBeUndefined();
645
+ expect(config.profiles.llm.default.provider).toBe("openai");
646
+ expect(config.profiles.llm.default.model).toBe("gpt-4o");
647
+ expect(config.defaults.llm).toBe("default");
684
648
  expect(config.embedding.model).toBe("text-embedding-3-small");
685
649
  });
686
650
  });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Both `performSetup` (config writes) and `startDeploy` (Docker work) need an
5
5
  * exclusive lock against concurrent installs. The lock file lives at
6
- * `<stateDir>/.install.lock` and contains `<pid>\n<timestamp>\n`.
6
+ * `<dataDir>/.install.lock` and contains `<pid>\n<timestamp>\n`.
7
7
  *
8
8
  * Self-healing rules:
9
9
  * - On EEXIST, parse the holder PID. If the process is gone (`process.kill(pid, 0)`
@@ -89,23 +89,23 @@ function tryCreate(path: string): boolean {
89
89
  }
90
90
 
91
91
  /**
92
- * Try to acquire the install lock under `stateDir`. Returns a handle on
92
+ * Try to acquire the install lock under `dataDir`. Returns a handle on
93
93
  * success or null if the lock is held by a live, recent install (or on any
94
94
  * unexpected filesystem error — caller should surface "install_in_progress").
95
95
  *
96
96
  * Callers MUST call `releaseInstallLock()` in a finally block when done.
97
97
  */
98
- export function acquireInstallLock(stateDir: string): InstallLockHandle | null {
98
+ export function acquireInstallLock(dataDir: string): InstallLockHandle | null {
99
99
  try {
100
- mkdirSync(stateDir, { recursive: true });
100
+ mkdirSync(dataDir, { recursive: true });
101
101
  } catch (err) {
102
- logger.warn("failed to ensure state dir for install lock", {
103
- stateDir,
102
+ logger.warn("failed to ensure data dir for install lock", {
103
+ dataDir,
104
104
  error: err instanceof Error ? err.message : String(err),
105
105
  });
106
106
  return null;
107
107
  }
108
- const path = join(stateDir, ".install.lock");
108
+ const path = join(dataDir, ".install.lock");
109
109
 
110
110
  try {
111
111
  if (tryCreate(path)) return { path };