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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) 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 +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. 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";
@@ -23,11 +23,11 @@ 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
29
  import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
30
+ import { readSecret } from './secrets-files.js';
31
31
 
32
32
  // ── Helpers ──────────────────────────────────────────────────────────────
33
33
 
@@ -55,70 +55,69 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
55
55
  function seedRequiredAssets(homeDir: string): void {
56
56
  mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
57
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");
58
+ mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
59
+ writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
60
+ writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
61
+ mkdirSync(join(homeDir, "data"), { recursive: true });
62
+ // Automations live in knowledge/tasks as AKM-owned task files.
63
+ mkdirSync(join(homeDir, "knowledge", "tasks"), { recursive: true });
64
+ writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
65
+ writeFileSync(join(homeDir, "knowledge", "tasks", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
66
+ writeFileSync(join(homeDir, "knowledge", "tasks", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
67
67
  }
68
68
 
69
69
  // ── Shared test fixture ──────────────────────────────────────────────────
70
70
 
71
71
  let homeDir: string;
72
72
  let configDir: string;
73
- let stateDir: string;
73
+ let dataDir: string;
74
74
  let stackDir: string;
75
- let cacheDir: string;
76
75
 
77
76
  const savedEnv: Record<string, string | undefined> = {};
78
77
 
79
78
  function saveAndSetEnv(): void {
80
79
  savedEnv.OP_HOME = process.env.OP_HOME;
80
+ savedEnv.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
81
+ savedEnv.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
81
82
  process.env.OP_HOME = homeDir;
83
+ delete process.env.OP_UI_LOGIN_PASSWORD;
84
+ delete process.env.OP_OPENCODE_PASSWORD;
82
85
  }
83
86
 
84
87
  function restoreEnv(): void {
85
- process.env.OP_HOME = savedEnv.OP_HOME;
88
+ if (savedEnv.OP_HOME === undefined) delete process.env.OP_HOME;
89
+ else process.env.OP_HOME = savedEnv.OP_HOME;
90
+ if (savedEnv.OP_UI_LOGIN_PASSWORD === undefined) delete process.env.OP_UI_LOGIN_PASSWORD;
91
+ else process.env.OP_UI_LOGIN_PASSWORD = savedEnv.OP_UI_LOGIN_PASSWORD;
92
+ if (savedEnv.OP_OPENCODE_PASSWORD === undefined) delete process.env.OP_OPENCODE_PASSWORD;
93
+ else process.env.OP_OPENCODE_PASSWORD = savedEnv.OP_OPENCODE_PASSWORD;
86
94
  }
87
95
 
88
96
  /** Create a full directory tree matching ensureHomeDirs() output. */
89
97
  function createFullDirTree(): void {
90
98
  homeDir = mkdtempSync(join(tmpdir(), "openpalm-edge-"));
91
99
  configDir = join(homeDir, "config");
92
- stateDir = join(homeDir, "state");
100
+ dataDir = join(homeDir, "data");
93
101
  stackDir = join(configDir, "stack");
94
- cacheDir = join(homeDir, "cache");
95
102
 
96
103
  for (const dir of [
97
104
  homeDir,
98
105
  configDir,
99
- join(homeDir, "state", "registry", "automations"),
100
106
  join(configDir, "assistant"),
101
107
  join(configDir, "akm"),
102
- join(homeDir, "stash"),
108
+ join(homeDir, "knowledge"),
109
+ join(homeDir, "knowledge", "env"),
110
+ join(homeDir, "knowledge", "secrets"),
103
111
  join(homeDir, "workspace"),
104
112
  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"),
113
+ dataDir,
114
+ join(dataDir, "assistant"),
115
+ join(dataDir, "guardian"),
116
+ join(dataDir, "akm", "cache"),
117
+ join(dataDir, "akm", "data"),
118
+ join(dataDir, "logs"),
119
+ join(dataDir, "backups"),
120
+ join(dataDir, "rollback"),
122
121
  ]) {
123
122
  mkdirSync(dir, { recursive: true });
124
123
  }
@@ -132,16 +131,10 @@ function seedMinimalEnvFiles(): void {
132
131
  mkdirSync(stackDir, { recursive: true });
133
132
 
134
133
  writeFileSync(
135
- join(stackDir, "stack.env"),
134
+ join(homeDir, "knowledge", "env", "stack.env"),
136
135
  [
137
136
  "# OpenPalm — Stack Configuration",
138
- "OP_UI_LOGIN_PASSWORD=",
139
- "OPENAI_API_KEY=",
140
137
  "OPENAI_BASE_URL=",
141
- "ANTHROPIC_API_KEY=",
142
- "GROQ_API_KEY=",
143
- "MISTRAL_API_KEY=",
144
- "GOOGLE_API_KEY=",
145
138
  "OP_OWNER_NAME=",
146
139
  "OP_OWNER_EMAIL=",
147
140
  "",
@@ -166,16 +159,15 @@ describe("Fresh Install", () => {
166
159
  rmSync(homeDir, { recursive: true, force: true });
167
160
  });
168
161
 
169
- // Scenario 1: ensureSecrets does NOT seed user.env (see akm-vault) but
162
+ // Scenario 1: ensureSecrets does NOT seed user.env (see akm-user-env) but
170
163
  // 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", () => {
164
+ it("ensureSecrets creates stack.env with required keys on fresh install", () => {
172
165
  const state: ControlPlaneState = {
173
166
  homeDir,
174
167
  configDir,
175
- stashDir: join(homeDir, "stash"),
168
+ stashDir: join(homeDir, "knowledge"),
176
169
  workspaceDir: join(homeDir, "workspace"),
177
- cacheDir,
178
- stateDir,
170
+ dataDir,
179
171
  stackDir,
180
172
  services: {},
181
173
  artifacts: { compose: "" },
@@ -184,17 +176,18 @@ describe("Fresh Install", () => {
184
176
 
185
177
  ensureSecrets(state);
186
178
 
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=");
179
+ // stack.env only carries non-secret setup/config keys.
180
+ const stackContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
181
+ expect(stackContent).not.toContain("OPENAI_API_KEY=");
182
+ expect(stackContent).toContain("OP_SETUP_COMPLETE=false");
183
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
191
184
  });
192
185
 
193
186
  // Scenario 2: isSetupComplete returns false before setup
194
187
  it("isSetupComplete returns false when stack.env has OP_SETUP_COMPLETE=false", () => {
195
- mkdirSync(stateDir, { recursive: true });
188
+ mkdirSync(dataDir, { recursive: true });
196
189
  writeFileSync(
197
- join(stackDir, "stack.env"),
190
+ join(homeDir, "knowledge", "env", "stack.env"),
198
191
  "OP_SETUP_COMPLETE=false\n"
199
192
  );
200
193
 
@@ -223,7 +216,7 @@ describe("Fresh Install", () => {
223
216
 
224
217
  await performSetup(makeValidSpec());
225
218
 
226
- const stackEnv = readFileSync(join(stackDir, "stack.env"), "utf-8");
219
+ const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
227
220
  const parsed = parseEnvContent(stackEnv);
228
221
  // Either entirely absent, or still the seeded "false" — never "true".
229
222
  expect(parsed.OP_SETUP_COMPLETE === undefined || parsed.OP_SETUP_COMPLETE === "false").toBe(true);
@@ -246,18 +239,17 @@ describe("Existing Install", () => {
246
239
  rmSync(homeDir, { recursive: true, force: true });
247
240
  });
248
241
 
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");
242
+ // Scenario 5: ensureSecrets creates file-based secrets without stack.env tokens
243
+ it("ensureSecrets creates file-based system secrets", () => {
244
+ mkdirSync(dataDir, { recursive: true });
245
+ writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
253
246
 
254
247
  const state: ControlPlaneState = {
255
248
  homeDir,
256
249
  configDir,
257
- stashDir: join(homeDir, "stash"),
250
+ stashDir: join(homeDir, "knowledge"),
258
251
  workspaceDir: join(homeDir, "workspace"),
259
- cacheDir,
260
- stateDir,
252
+ dataDir,
261
253
  stackDir,
262
254
  services: {},
263
255
  artifacts: { compose: "" },
@@ -266,25 +258,23 @@ describe("Existing Install", () => {
266
258
 
267
259
  ensureSecrets(state);
268
260
 
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");
261
+ const afterContent = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
262
+ expect(afterContent).not.toContain("OP_UI_LOGIN_PASSWORD=");
263
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBeNull();
272
264
  });
273
265
 
274
266
  // Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
275
267
  // operator supplies a new one in the spec. This is intentional — the
276
268
  // wizard "rerun" path is how an operator rotates the password. The
277
269
  // 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 () => {
270
+ it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD secret file when spec changes", async () => {
279
271
  await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
280
272
 
281
- const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
282
- expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
273
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("first-password-12345\n");
283
274
 
284
275
  await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
285
276
 
286
- const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
287
- expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
277
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("second-password-12345\n");
288
278
  });
289
279
 
290
280
  // Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
@@ -294,7 +284,7 @@ describe("Existing Install", () => {
294
284
  await performSetup(makeValidSpec());
295
285
 
296
286
  const stackEnv = readFileSync(
297
- join(stackDir, "stack.env"),
287
+ join(homeDir, "knowledge", "env", "stack.env"),
298
288
  "utf-8"
299
289
  );
300
290
  const parsed = parseEnvContent(stackEnv);
@@ -328,9 +318,8 @@ describe("Existing Install", () => {
328
318
  expect(specAfterSecond).not.toBeNull();
329
319
  expect(specAfterSecond!.version).toBe(2);
330
320
 
331
- // stack.env should retain both keys
332
- const secrets = readFileSync(join(stackDir, "stack.env"), "utf-8");
333
- expect(secrets).toContain("GROQ_API_KEY");
321
+ const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
322
+ expect(auth.groq.key).toBe("gsk-test-key-456");
334
323
  });
335
324
  });
336
325
 
@@ -351,16 +340,15 @@ describe("Broken/Corrupt State", () => {
351
340
 
352
341
  // Scenario 9: ensureSecrets is idempotent on repeated calls
353
342
  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");
343
+ mkdirSync(dataDir, { recursive: true });
344
+ writeFileSync(join(homeDir, "knowledge", "env", "stack.env"), "OP_SETUP_COMPLETE=false\n");
356
345
 
357
346
  const state: ControlPlaneState = {
358
347
  homeDir,
359
348
  configDir,
360
- stashDir: join(homeDir, "stash"),
349
+ stashDir: join(homeDir, "knowledge"),
361
350
  workspaceDir: join(homeDir, "workspace"),
362
- cacheDir,
363
- stateDir,
351
+ dataDir,
364
352
  stackDir,
365
353
  services: {},
366
354
  artifacts: { compose: "" },
@@ -369,9 +357,10 @@ describe("Broken/Corrupt State", () => {
369
357
 
370
358
  ensureSecrets(state);
371
359
 
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");
360
+ // Existing non-secret stack config must be preserved.
361
+ const content = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8");
362
+ expect(content).toContain("OP_SETUP_COMPLETE=false");
363
+ expect(content).not.toContain("OP_UI_LOGIN_PASSWORD=");
375
364
  });
376
365
 
377
366
  // Scenario 10: env file with malformed lines
@@ -388,10 +377,10 @@ describe("Broken/Corrupt State", () => {
388
377
  " # indented comment",
389
378
  ].join("\n");
390
379
 
391
- mkdirSync(stateDir, { recursive: true });
392
- writeFileSync(join(stateDir, "test.env"), malformedContent);
380
+ mkdirSync(dataDir, { recursive: true });
381
+ writeFileSync(join(dataDir, "test.env"), malformedContent);
393
382
 
394
- const parsed = parseEnvFile(join(stateDir, "test.env"));
383
+ const parsed = parseEnvFile(join(dataDir, "test.env"));
395
384
  expect(parsed.VALID_KEY).toBe("valid_value");
396
385
  expect(parsed.EXPORTED_KEY).toBe("exported_value");
397
386
  expect(parsed.ANOTHER_VALID).toBe("value");
@@ -400,23 +389,25 @@ describe("Broken/Corrupt State", () => {
400
389
  // Scenario 11: stack.env missing OP_SETUP_COMPLETE
401
390
  it("isSetupComplete falls back to token check when OP_SETUP_COMPLETE missing", () => {
402
391
  // stack.env without OP_SETUP_COMPLETE
403
- mkdirSync(stateDir, { recursive: true });
392
+ mkdirSync(dataDir, { recursive: true });
404
393
  writeFileSync(
405
- join(stackDir, "stack.env"),
394
+ join(homeDir, "knowledge", "env", "stack.env"),
406
395
  "OP_IMAGE_TAG=latest\n"
407
396
  );
408
397
 
409
398
  expect(isSetupComplete(stackDir)).toBe(false);
410
399
  });
411
400
 
412
- it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
413
- mkdirSync(stateDir, { recursive: true });
401
+ it("isSetupComplete returns false when OP_UI_LOGIN_PASSWORD is set but OP_SETUP_COMPLETE is missing", () => {
402
+ mkdirSync(dataDir, { recursive: true });
414
403
  writeFileSync(
415
- join(stackDir, "stack.env"),
404
+ join(homeDir, "knowledge", "env", "stack.env"),
416
405
  "OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
417
406
  );
418
407
 
419
- expect(isSetupComplete(stackDir)).toBe(true);
408
+ // Password alone is no longer a proxy for setup completion.
409
+ // Only OP_SETUP_COMPLETE=true counts.
410
+ expect(isSetupComplete(stackDir)).toBe(false);
420
411
  });
421
412
 
422
413
  // Scenario 12: API key with special characters round-trips
@@ -441,13 +432,13 @@ describe("Broken/Corrupt State", () => {
441
432
  expect(spec).toBeNull();
442
433
  });
443
434
 
444
- // Scenario 14: stash/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
435
+ // Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
445
436
  it("performSetup creates missing subdirectories", async () => {
446
437
  // Seed the minimal env files first
447
438
  seedMinimalEnvFiles();
448
439
 
449
- // Remove stash/tasks dir (performSetup should recreate it via ensureHomeDirs)
450
- rmSync(join(homeDir, "stash", "tasks"), { recursive: true, force: true });
440
+ // Remove knowledge/tasks dir (performSetup should recreate it via ensureHomeDirs)
441
+ rmSync(join(homeDir, "knowledge", "tasks"), { recursive: true, force: true });
451
442
 
452
443
  const result = await performSetup(
453
444
  makeValidSpec()
@@ -458,8 +449,8 @@ describe("Broken/Corrupt State", () => {
458
449
  expect(existsSync(join(homeDir, "config", "stack", "core.compose.yml"))).toBe(
459
450
  true
460
451
  );
461
- // stash/tasks dir should be recreated by ensureHomeDirs
462
- expect(existsSync(join(homeDir, "stash", "tasks"))).toBe(true);
452
+ // knowledge/tasks dir should be recreated by ensureHomeDirs
453
+ expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
463
454
  });
464
455
 
465
456
  // Scenario 15: openpalm.yaml with old version
@@ -489,15 +480,15 @@ describe("Environment Edge Cases", () => {
489
480
  rmSync(homeDir, { recursive: true, force: true });
490
481
  });
491
482
 
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 });
483
+ // Scenario 16: isSetupComplete requires explicit OP_SETUP_COMPLETE=true
484
+ it("isSetupComplete returns false when only OP_UI_LOGIN_PASSWORD is set", () => {
485
+ mkdirSync(dataDir, { recursive: true });
495
486
  writeFileSync(
496
- join(stackDir, "stack.env"),
487
+ join(homeDir, "knowledge", "env", "stack.env"),
497
488
  "SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
498
489
  );
499
490
 
500
- expect(isSetupComplete(stackDir)).toBe(true);
491
+ expect(isSetupComplete(stackDir)).toBe(false);
501
492
  });
502
493
 
503
494
  // Scenario 17: export prefix on env vars
@@ -667,11 +658,10 @@ describe("performSetup end-to-end artifacts", () => {
667
658
  ).toBe(true);
668
659
  });
669
660
 
670
- it("writes the UI login password to stack.env", async () => {
661
+ it("writes the UI login password to a secret file", async () => {
671
662
  await performSetup(makeValidSpec());
672
663
 
673
- const secrets = parseEnvFile(join(stackDir, "stack.env"));
674
- expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
664
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
675
665
  });
676
666
 
677
667
  it("writes akm config with llm provider and model", async () => {
@@ -679,8 +669,11 @@ describe("performSetup end-to-end artifacts", () => {
679
669
 
680
670
  const akmConfigPath = join(homeDir, "config", "akm", "config.json");
681
671
  const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
682
- expect(config.llm.provider).toBe("openai");
683
- expect(config.llm.model).toBe("gpt-4o");
672
+ // Canonical akm 0.8.0 shape (I-3): profiles.llm.default + defaults.llm.
673
+ expect(config.llm).toBeUndefined();
674
+ expect(config.profiles.llm.default.provider).toBe("openai");
675
+ expect(config.profiles.llm.default.model).toBe("gpt-4o");
676
+ expect(config.defaults.llm).toBe("default");
684
677
  expect(config.embedding.model).toBe("text-embedding-3-small");
685
678
  });
686
679
  });
@@ -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 };