@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.13

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 (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-user-env.test.ts +113 -0
  4. package/src/control-plane/akm-user-env.ts +144 -0
  5. package/src/control-plane/backup.ts +14 -5
  6. package/src/control-plane/channels.ts +48 -29
  7. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  8. package/src/control-plane/compose-args.test.ts +90 -31
  9. package/src/control-plane/compose-args.ts +119 -9
  10. package/src/control-plane/config-persistence.ts +87 -133
  11. package/src/control-plane/core-assets.test.ts +9 -9
  12. package/src/control-plane/core-assets.ts +24 -8
  13. package/src/control-plane/docker.ts +15 -14
  14. package/src/control-plane/env.test.ts +10 -10
  15. package/src/control-plane/env.ts +1 -1
  16. package/src/control-plane/extends-support.test.ts +8 -8
  17. package/src/control-plane/home.ts +34 -46
  18. package/src/control-plane/host-opencode.test.ts +82 -10
  19. package/src/control-plane/host-opencode.ts +42 -13
  20. package/src/control-plane/install-edge-cases.test.ts +94 -102
  21. package/src/control-plane/install-lock.ts +7 -7
  22. package/src/control-plane/lifecycle.ts +36 -34
  23. package/src/control-plane/markdown-task.ts +30 -50
  24. package/src/control-plane/paths.ts +62 -42
  25. package/src/control-plane/profile-ids.ts +21 -0
  26. package/src/control-plane/provider-models.ts +3 -3
  27. package/src/control-plane/registry.test.ts +97 -88
  28. package/src/control-plane/registry.ts +142 -110
  29. package/src/control-plane/rollback.ts +8 -38
  30. package/src/control-plane/scheduler.ts +7 -7
  31. package/src/control-plane/secret-audit.test.ts +159 -0
  32. package/src/control-plane/secret-audit.ts +255 -0
  33. package/src/control-plane/secret-mappings.ts +2 -2
  34. package/src/control-plane/secrets-files.test.ts +60 -0
  35. package/src/control-plane/secrets-files.ts +66 -0
  36. package/src/control-plane/secrets.ts +113 -86
  37. package/src/control-plane/setup-config.schema.json +1 -1
  38. package/src/control-plane/setup-status.ts +6 -11
  39. package/src/control-plane/setup.test.ts +42 -40
  40. package/src/control-plane/setup.ts +36 -31
  41. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  42. package/src/control-plane/spec-to-env.test.ts +22 -17
  43. package/src/control-plane/spec-to-env.ts +7 -2
  44. package/src/control-plane/stack-spec.test.ts +10 -0
  45. package/src/control-plane/stack-spec.ts +28 -1
  46. package/src/control-plane/types.ts +2 -4
  47. package/src/control-plane/ui-assets.ts +60 -58
  48. package/src/control-plane/validate.ts +13 -15
  49. package/src/index.ts +47 -15
  50. package/src/control-plane/akm-vault.test.ts +0 -105
  51. package/src/control-plane/akm-vault.ts +0 -311
  52. package/src/control-plane/migrate-0110.test.ts +0 -177
  53. package/src/control-plane/migrate-0110.ts +0 -99
  54. package/src/control-plane/registry-components.test.ts +0 -391
@@ -11,12 +11,14 @@
11
11
  * - auth.json is copied byte-for-byte and chmodded 0o600. Its contents
12
12
  * are never parsed, logged, or returned to callers.
13
13
  * - opencode.json is parsed to strip plugin/mcp/permission keys before
14
- * writing; only provider/model/small_model/disabled_providers are kept.
14
+ * writing; provider definitions are always kept, and top-level model
15
+ * defaults are imported only when OP_HOME does not already define them.
15
16
  * - Conflict detection compares provider IDs; existing credentials are
16
17
  * preserved unless overwriteConflicts=true.
17
18
  */
18
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from "node:fs";
19
20
  import { homedir } from "node:os";
21
+ import { dirname } from "node:path";
20
22
  import type { ControlPlaneState } from "./types.js";
21
23
  import { authJsonPath, assistantConfigDir } from "./paths.js";
22
24
 
@@ -31,6 +33,8 @@ export type HostOpenCodeStatus = {
31
33
  providerCount: number;
32
34
  /** Number of credential entries in auth.json (0 when not found) */
33
35
  credentialCount: number;
36
+ /** Model preferences from the host's opencode.json, if present */
37
+ modelPreferences?: { model?: string; small_model?: string };
34
38
  };
35
39
 
36
40
  export type HostImportResult = {
@@ -64,7 +68,7 @@ function hostAuthJsonPath(): string {
64
68
 
65
69
  // ── opencode.json parsing ────────────────────────────────────────────────────
66
70
 
67
- /** Keys that are safe to import from host opencode.json into OP_HOME config */
71
+ /** Keys that are safe to import from host opencode.json into OP_HOME config. */
68
72
  const ALLOWED_CONFIG_KEYS = new Set(["$schema", "provider", "model", "small_model", "disabled_providers"]);
69
73
 
70
74
  type OpenCodeJson = Record<string, unknown>;
@@ -78,8 +82,18 @@ function readJsonFileSafe(path: string): OpenCodeJson | null {
78
82
  }
79
83
 
80
84
  function stripDisallowedKeys(obj: OpenCodeJson): OpenCodeJson {
85
+ const next: OpenCodeJson = {};
86
+ if (typeof obj.$schema === 'string') next.$schema = obj.$schema;
87
+ if (obj.provider && typeof obj.provider === 'object' && !Array.isArray(obj.provider)) {
88
+ next.provider = obj.provider;
89
+ }
90
+ if (typeof obj.model === 'string') next.model = obj.model;
91
+ if (typeof obj.small_model === 'string') next.small_model = obj.small_model;
92
+ if (Array.isArray(obj.disabled_providers) && obj.disabled_providers.every((entry) => typeof entry === 'string')) {
93
+ next.disabled_providers = obj.disabled_providers;
94
+ }
81
95
  return Object.fromEntries(
82
- Object.entries(obj).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
96
+ Object.entries(next).filter(([k]) => ALLOWED_CONFIG_KEYS.has(k))
83
97
  );
84
98
  }
85
99
 
@@ -116,9 +130,16 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
116
130
  }
117
131
 
118
132
  let providerCount = 0;
133
+ let modelPreferences: { model?: string; small_model?: string } | undefined;
119
134
  if (configExists) {
120
135
  const parsed = readJsonFileSafe(configPath);
121
136
  providerCount = parsed ? countProviders(parsed) : 0;
137
+ if (parsed) {
138
+ const prefs: { model?: string; small_model?: string } = {};
139
+ if (typeof parsed.model === 'string' && parsed.model) prefs.model = parsed.model;
140
+ if (typeof parsed.small_model === 'string' && parsed.small_model) prefs.small_model = parsed.small_model;
141
+ if (prefs.model || prefs.small_model) modelPreferences = prefs;
142
+ }
122
143
  }
123
144
 
124
145
  let credentialCount = 0;
@@ -131,6 +152,7 @@ export function detectHostOpenCode(): HostOpenCodeStatus {
131
152
  authPath: authExists ? authPath : undefined,
132
153
  providerCount,
133
154
  credentialCount,
155
+ ...(modelPreferences ? { modelPreferences } : {}),
134
156
  };
135
157
  }
136
158
 
@@ -181,21 +203,28 @@ export function importHostOpenCode(
181
203
  }
182
204
  }
183
205
 
184
- const merged: OpenCodeJson = {
185
- ...existing,
186
- ...sanitized,
187
- ...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
188
- };
189
-
190
- writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
191
- }
206
+ const merged: OpenCodeJson = {
207
+ ...existing,
208
+ ...(typeof existing.$schema === 'undefined' && typeof sanitized.$schema !== 'undefined'
209
+ ? { $schema: sanitized.$schema }
210
+ : {}),
211
+ ...(Object.keys(mergedProviders).length > 0 ? { provider: mergedProviders } : {}),
212
+ };
213
+
214
+ for (const key of ["model", "small_model", "disabled_providers"] as const) {
215
+ if (typeof merged[key] === 'undefined' && typeof sanitized[key] !== 'undefined') {
216
+ merged[key] = sanitized[key];
217
+ }
218
+ }
219
+
220
+ writeFileSync(destPath, JSON.stringify(merged, null, 2) + "\n");
221
+ }
192
222
  }
193
223
 
194
224
  // ── auth.json ──────────────────────────────────────────────────────────
195
225
  if (status.authPath) {
196
226
  const destPath = authJsonPath(state);
197
- const destDir = state.configDir;
198
- mkdirSync(destDir, { recursive: true });
227
+ mkdirSync(dirname(destPath), { recursive: true, mode: 0o700 });
199
228
 
200
229
  if (existsSync(destPath) && !overwriteConflicts) {
201
230
  // Merge: copy only keys that do not already exist in OP_HOME auth.json
@@ -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";
@@ -28,6 +28,7 @@ import {
28
28
  import type { SetupSpec, SetupConnection } from "./setup.js";
29
29
  import type { ControlPlaneState } from "./types.js";
30
30
  import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
31
+ import { readSecret } from './secrets-files.js';
31
32
 
32
33
  // ── Helpers ──────────────────────────────────────────────────────────────
33
34
 
@@ -55,70 +56,70 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
55
56
  function seedRequiredAssets(homeDir: string): void {
56
57
  mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
57
58
  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");
59
+ mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
60
+ writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
61
+ writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
62
+ mkdirSync(join(homeDir, "data"), { recursive: true });
63
+ // Automations live in knowledge/tasks as AKM-owned task files.
64
+ mkdirSync(join(homeDir, "knowledge", "tasks"), { recursive: true });
65
+ writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
66
+ writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
67
+ writeFileSync(join(homeDir, "knowledge", "tasks", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
67
68
  }
68
69
 
69
70
  // ── Shared test fixture ──────────────────────────────────────────────────
70
71
 
71
72
  let homeDir: string;
72
73
  let configDir: string;
73
- let stateDir: string;
74
+ let dataDir: string;
74
75
  let stackDir: string;
75
- let cacheDir: string;
76
76
 
77
77
  const savedEnv: Record<string, string | undefined> = {};
78
78
 
79
79
  function saveAndSetEnv(): void {
80
80
  savedEnv.OP_HOME = process.env.OP_HOME;
81
+ savedEnv.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
82
+ savedEnv.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
81
83
  process.env.OP_HOME = homeDir;
84
+ delete process.env.OP_UI_LOGIN_PASSWORD;
85
+ delete process.env.OP_OPENCODE_PASSWORD;
82
86
  }
83
87
 
84
88
  function restoreEnv(): void {
85
- process.env.OP_HOME = savedEnv.OP_HOME;
89
+ if (savedEnv.OP_HOME === undefined) delete process.env.OP_HOME;
90
+ else process.env.OP_HOME = savedEnv.OP_HOME;
91
+ if (savedEnv.OP_UI_LOGIN_PASSWORD === undefined) delete process.env.OP_UI_LOGIN_PASSWORD;
92
+ else process.env.OP_UI_LOGIN_PASSWORD = savedEnv.OP_UI_LOGIN_PASSWORD;
93
+ if (savedEnv.OP_OPENCODE_PASSWORD === undefined) delete process.env.OP_OPENCODE_PASSWORD;
94
+ else process.env.OP_OPENCODE_PASSWORD = savedEnv.OP_OPENCODE_PASSWORD;
86
95
  }
87
96
 
88
97
  /** Create a full directory tree matching ensureHomeDirs() output. */
89
98
  function createFullDirTree(): void {
90
99
  homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
91
100
  configDir = join(homeDir, "config");
92
- stateDir = join(homeDir, "state");
101
+ dataDir = join(homeDir, "data");
93
102
  stackDir = join(configDir, "stack");
94
- cacheDir = join(homeDir, "cache");
95
103
 
96
104
  for (const dir of [
97
105
  homeDir,
98
106
  configDir,
99
- join(homeDir, "state", "registry", "automations"),
100
107
  join(configDir, "assistant"),
101
108
  join(configDir, "akm"),
102
- join(homeDir, "stash"),
109
+ join(homeDir, "knowledge"),
110
+ join(homeDir, "knowledge", "env"),
111
+ join(homeDir, "knowledge", "secrets"),
103
112
  join(homeDir, "workspace"),
104
113
  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"),
114
+ dataDir,
115
+ join(dataDir, "assistant"),
116
+ join(dataDir, "admin"),
117
+ join(dataDir, "guardian"),
118
+ join(dataDir, "akm", "cache"),
119
+ join(dataDir, "akm", "data"),
120
+ join(dataDir, "logs"),
121
+ join(dataDir, "backups"),
122
+ join(dataDir, "rollback"),
122
123
  ]) {
123
124
  mkdirSync(dir, { recursive: true });
124
125
  }
@@ -132,16 +133,10 @@ function seedMinimalEnvFiles(): void {
132
133
  mkdirSync(stackDir, { recursive: true });
133
134
 
134
135
  writeFileSync(
135
- join(stackDir, "stack.env"),
136
+ join(homeDir, "knowledge", "env", "stack.env"),
136
137
  [
137
138
  "# OpenPalm — Stack Configuration",
138
- "OP_UI_LOGIN_PASSWORD=",
139
- "OPENAI_API_KEY=",
140
139
  "OPENAI_BASE_URL=",
141
- "ANTHROPIC_API_KEY=",
142
- "GROQ_API_KEY=",
143
- "MISTRAL_API_KEY=",
144
- "GOOGLE_API_KEY=",
145
140
  "OP_OWNER_NAME=",
146
141
  "OP_OWNER_EMAIL=",
147
142
  "",
@@ -166,16 +161,15 @@ describe("Fresh Install", () => {
166
161
  rmSync(homeDir, { recursive: true, force: true });
167
162
  });
168
163
 
169
- // Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
164
+ // Scenario 1: ensureSecrets does NOT seed user.env (see akm-user-env) but
170
165
  // 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", () => {
166
+ it("ensureSecrets creates stack.env with required keys on fresh install", () => {
172
167
  const state: ControlPlaneState = {
173
168
  homeDir,
174
169
  configDir,
175
- stashDir: join(homeDir, "stash"),
170
+ stashDir: join(homeDir, "knowledge"),
176
171
  workspaceDir: join(homeDir, "workspace"),
177
- cacheDir,
178
- stateDir,
172
+ dataDir,
179
173
  stackDir,
180
174
  services: {},
181
175
  artifacts: { compose: "" },
@@ -184,17 +178,18 @@ describe("Fresh Install", () => {
184
178
 
185
179
  ensureSecrets(state);
186
180
 
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=");
181
+ // stack.env only carries non-secret setup/config keys.
182
+ const stackContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
183
+ expect(stackContent).not.toContain("OPENAI_API_KEY=");
184
+ expect(stackContent).toContain("OP_SETUP_COMPLETE=false");
185
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
191
186
  });
192
187
 
193
188
  // Scenario 2: isSetupComplete returns false before setup
194
189
  it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
195
- mkdirSync(stateDir, { recursive: true });
190
+ mkdirSync(dataDir, { recursive: true });
196
191
  writeFileSync(
197
- join(stackDir, "stack.env"),
192
+ join(homeDir, "knowledge", "env", "stack.env"),
198
193
  "OP_SETUP_COMPLETE=false\n"
199
194
  );
200
195
 
@@ -223,7 +218,7 @@ describe("Fresh Install", () => {
223
218
 
224
219
  await performSetup(makeValidSpec());
225
220
 
226
- const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
221
+ const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
227
222
  const parsed = parseEnvContent(stackEnv);
228
223
  // Either entirely absent, or still the seeded "false" — never "true".
229
224
  expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
@@ -246,18 +241,17 @@ describe("Existing Install", () => {
246
241
  rmSync(homeDir, { recursive: true, force: true });
247
242
  });
248
243
 
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");
244
+ // Scenario 5: ensureSecrets creates file-based secrets without stack.env tokens
245
+ it("ensureSecrets creates file-based system secrets", () => {
246
+ mkdirSync(dataDir, { recursive: true });
247
+ writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
253
248
 
254
249
  const state: ControlPlaneState = {
255
250
  homeDir,
256
251
  configDir,
257
- stashDir: join(homeDir, "stash"),
252
+ stashDir: join(homeDir, "knowledge"),
258
253
  workspaceDir: join(homeDir, "workspace"),
259
- cacheDir,
260
- stateDir,
254
+ dataDir,
261
255
  stackDir,
262
256
  services: {},
263
257
  artifacts: { compose: "" },
@@ -266,25 +260,23 @@ describe("Existing Install", () => {
266
260
 
267
261
  ensureSecrets(state);
268
262
 
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");
263
+ const afterContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
264
+ expect(afterContent).not.toContain("OP_UI_LOGIN_PASSWORD=");
265
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
272
266
  });
273
267
 
274
268
  // Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
275
269
  // operator supplies a new one in the spec. This is intentional — the
276
270
  // wizard "rerun" path is how an operator rotates the password. The
277
271
  // 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 () => {
272
+ it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD secret file when spec changes", async () => {
279
273
  await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
280
274
 
281
- const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
282
- expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
275
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("first-password-12345\n");
283
276
 
284
277
  await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
285
278
 
286
- const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
287
- expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
279
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("second-password-12345\n");
288
280
  });
289
281
 
290
282
  // Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
@@ -294,7 +286,7 @@ describe("Existing Install", () => {
294
286
  await performSetup(makeValidSpec());
295
287
 
296
288
  const stackEnv = readFileSync(
297
- join(stackDir, "stack.env"),
289
+ join(homeDir, "knowledge", "env", "stack.env"),
298
290
  "utf-8"
299
291
  );
300
292
  const parsed = parseEnvContent(stackEnv);
@@ -328,9 +320,8 @@ describe("Existing Install", () => {
328
320
  expect(specAfterSecond).not.toBeNull();
329
321
  expect(specAfterSecond!.version).toBe(2);
330
322
 
331
- // stack.env should retain both keys
332
- const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
333
- expect(secrets).toContain("GROQ_API_KEY");
323
+ const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
324
+ expect(auth.groq.key).toBe("gsk-test-key-456");
334
325
  });
335
326
  });
336
327
 
@@ -351,16 +342,15 @@ describe("Broken/Corrupt State", () => {
351
342
 
352
343
  // Scenario 9: ensureSecrets is idempotent on repeated calls
353
344
  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");
345
+ mkdirSync(dataDir, { recursive: true });
346
+ writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
356
347
 
357
348
  const state: ControlPlaneState = {
358
349
  homeDir,
359
350
  configDir,
360
- stashDir: join(homeDir, "stash"),
351
+ stashDir: join(homeDir, "knowledge"),
361
352
  workspaceDir: join(homeDir, "workspace"),
362
- cacheDir,
363
- stateDir,
353
+ dataDir,
364
354
  stackDir,
365
355
  services: {},
366
356
  artifacts: { compose: "" },
@@ -369,9 +359,10 @@ describe("Broken/Corrupt State", () => {
369
359
 
370
360
  ensureSecrets(state);
371
361
 
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");
362
+ // Existing non-secret stack config must be preserved.
363
+ const content = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
364
+ expect(content).toContain("OP_SETUP_COMPLETE=false");
365
+ expect(content).not.toContain("OP_UI_LOGIN_PASSWORD=");
375
366
  });
376
367
 
377
368
  // Scenario 10: env file with malformed lines
@@ -388,10 +379,10 @@ describe("Broken/Corrupt State", () => {
388
379
  " # indented comment",
389
380
  ].join("\n");
390
381
 
391
- mkdirSync(stateDir, { recursive: true });
392
- writeFileSync(join(stateDir, "test.env"), malformedContent);
382
+ mkdirSync(dataDir, { recursive: true });
383
+ writeFileSync(join(dataDir, "test.env"), malformedContent);
393
384
 
394
- const parsed = parseEnvFile(join(stateDir, "test.env"));
385
+ const parsed = parseEnvFile(join(dataDir, "test.env"));
395
386
  expect(parsed.VALID_KEY).toBe("valid_value");
396
387
  expect(parsed.EXPORTED_KEY).toBe("exported_value");
397
388
  expect(parsed.ANOTHER_VALID).toBe("value");
@@ -400,23 +391,25 @@ describe("Broken/Corrupt State", () => {
400
391
  // Scenario 11: stack.env missing OP_SETUP_COMPLETE
401
392
  it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
402
393
  // stack.env without OP_SETUP_COMPLETE
403
- mkdirSync(stateDir, { recursive: true });
394
+ mkdirSync(dataDir, { recursive: true });
404
395
  writeFileSync(
405
- join(stackDir, "stack.env"),
396
+ join(homeDir, "knowledge", "env", "stack.env"),
406
397
  "OP_IMAGE_TAG=latest\n"
407
398
  );
408
399
 
409
400
  expect(isSetupComplete(stackDir)).toBe(false);
410
401
  });
411
402
 
412
- it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
413
- mkdirSync(stateDir, { recursive: true });
403
+ it("isSetupComplete returns false when OP_UI_LOGIN_PASSWORD is set but OP_SETUP_COMPLETE is missing", () => {
404
+ mkdirSync(dataDir, { recursive: true });
414
405
  writeFileSync(
415
- join(stackDir, "stack.env"),
406
+ join(homeDir, "knowledge", "env", "stack.env"),
416
407
  "OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
417
408
  );
418
409
 
419
- expect(isSetupComplete(stackDir)).toBe(true);
410
+ // Password alone is no longer a proxy for setup completion.
411
+ // Only OP_SETUP_COMPLETE=true counts.
412
+ expect(isSetupComplete(stackDir)).toBe(false);
420
413
  });
421
414
 
422
415
  // Scenario 12: API key with special characters round-trips
@@ -441,13 +434,13 @@ describe("Broken/Corrupt State", () => {
441
434
  expect(spec).toBeNull();
442
435
  });
443
436
 
444
- // Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
437
+ // Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
445
438
  it("performSetup creates missing subdirectories", async () => {
446
439
  // Seed the minimal env files first
447
440
  seedMinimalEnvFiles();
448
441
 
449
- // Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
450
- rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
442
+ // Remove knowledge/tasks dir (performSetup should recreate it via ensureHomeDirs)
443
+ rmSync(join(homeDir, "knowledge", "tasks"), { recursive: true, force: true });
451
444
 
452
445
  const result = await performSetup(
453
446
  makeValidSpec()
@@ -458,8 +451,8 @@ describe("Broken/Corrupt State", () => {
458
451
  expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
459
452
  true
460
453
  );
461
- // stash/tasks dir should be recreated by ensureHomeDirs
462
- expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
454
+ // knowledge/tasks dir should be recreated by ensureHomeDirs
455
+ expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
463
456
  });
464
457
 
465
458
  // Scenario 15: openpalm.yaml with old version
@@ -489,15 +482,15 @@ describe("Environment Edge Cases", () => {
489
482
  rmSync(homeDir, { recursive: true, force: true });
490
483
  });
491
484
 
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 });
485
+ // Scenario 16: isSetupComplete requires explicit OP_SETUP_COMPLETE=true
486
+ it("isSetupComplete returns false when only OP_UI_LOGIN_PASSWORD is set", () => {
487
+ mkdirSync(dataDir, { recursive: true });
495
488
  writeFileSync(
496
- join(stackDir, "stack.env"),
489
+ join(homeDir, "knowledge", "env", "stack.env"),
497
490
  "SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
498
491
  );
499
492
 
500
- expect(isSetupComplete(stackDir)).toBe(true);
493
+ expect(isSetupComplete(stackDir)).toBe(false);
501
494
  });
502
495
 
503
496
  // Scenario 17: export prefix on env vars
@@ -667,11 +660,10 @@ describe("performSetup end-to-end artifacts", () => {
667
660
  ).toBe(true);
668
661
  });
669
662
 
670
- it("writes the UI login password to stack.env", async () => {
663
+ it("writes the UI login password to a secret file", async () => {
671
664
  await performSetup(makeValidSpec());
672
665
 
673
- const secrets = parseEnvFile(join(stackDir, "stack.env"));
674
- expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
666
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
675
667
  });
676
668
 
677
669
  it("writes akm config with llm provider and model", async () => {
@@ -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 };