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

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 -109
  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
@@ -1,11 +1,12 @@
1
1
  /** Secrets and capability key management. */
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync, renameSync } from "node:fs";
3
- import { randomBytes } from "node:crypto";
4
3
  import { createLogger } from "../logger.js";
5
4
  import { parseEnvFile, mergeEnvContent } from './env.js';
6
- import { migrateAuth0110 } from './migrate-0110.js';
7
5
  import type { ControlPlaneState } from "./types.js";
8
6
  import { resolveConfigDir } from "./home.js";
7
+ import { authJsonPath as resolveAuthJsonPath, stackEnvPathFromStackDir } from "./paths.js";
8
+ import { dirname } from "node:path";
9
+ import { listSecretNames, readSecret, resolveSecretsDir, writeSecret } from './secrets-files.js';
9
10
 
10
11
  const OPENCODE_STARTER_CONFIG = JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n";
11
12
  const logger = createLogger("secrets");
@@ -22,6 +23,22 @@ export const PLAIN_CONFIG_KEYS = new Set([
22
23
  const VAULT_DIR_MODE = 0o700;
23
24
  const VAULT_FILE_MODE = 0o600;
24
25
 
26
+ export const SECRET_ENV_KEY_RE = /(?:^OP_UI_LOGIN_PASSWORD$|^OP_OPENCODE_PASSWORD$|_API_KEY$|_TOKEN$|_SECRET$|_PASSWORD$)/;
27
+ const SECRET_LIKE_STACK_ENV_KEY_RE = /(SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CLIENT_SECRET|AUTH_JSON|CREDENTIALS)/;
28
+ const NON_SECRET_STACK_ENV_KEY_ALLOWLIST = new Set<string>();
29
+
30
+ export function isSecretLikeStackEnvKey(key: string): boolean {
31
+ return SECRET_LIKE_STACK_ENV_KEY_RE.test(key) && !NON_SECRET_STACK_ENV_KEY_ALLOWLIST.has(key);
32
+ }
33
+
34
+ export function assertNoSecretLikeStackEnvKeys(updates: Record<string, string>): void {
35
+ for (const key of Object.keys(updates)) {
36
+ if (isSecretLikeStackEnvKey(key)) {
37
+ throw new Error(`Refusing to write secret-like key to stack.env: ${key}`);
38
+ }
39
+ }
40
+ }
41
+
25
42
  function enforceVaultDirMode(vaultDir: string): void {
26
43
  mkdirSync(vaultDir, { recursive: true, mode: VAULT_DIR_MODE });
27
44
  try {
@@ -46,8 +63,39 @@ function writeVaultFile(path: string, content: string): void {
46
63
  }
47
64
  }
48
65
 
66
+ export function stackSecretsDir(stackDir: string): string {
67
+ return resolveSecretsDir(stackDir);
68
+ }
69
+
70
+ export function stackSecretPath(stackDir: string, envKey: string): string {
71
+ return `${stackSecretsDir(stackDir)}/${envKey.toLowerCase()}`;
72
+ }
73
+
74
+ export function readStackSecretEnv(stackDir: string): Record<string, string> {
75
+ const out: Record<string, string> = {};
76
+ for (const name of listSecretNames(stackDir)) {
77
+ const envKey = name.toUpperCase();
78
+ try {
79
+ out[envKey] = (readSecret(stackDir, name) ?? '').replace(/[\r\n]+$/, '');
80
+ } catch {
81
+ // ignore unreadable secret files; callers treat missing values as absent
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ export function writeStackSecretEnv(state: ControlPlaneState, updates: Record<string, string>): void {
88
+ if (Object.keys(updates).length === 0) return;
89
+ resolveSecretsDir(state.stackDir);
90
+ for (const [envKey, value] of Object.entries(updates)) {
91
+ if (!/^[A-Z0-9_]+$/.test(envKey)) throw new Error(`Invalid secret env key: ${envKey}`);
92
+ writeSecret(state.stackDir, envKey.toLowerCase(), value.endsWith('\n') ? value : `${value}\n`);
93
+ }
94
+ }
95
+
49
96
  function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomment = false): void {
50
97
  if (Object.keys(updates).length === 0) return;
98
+ assertNoSecretLikeStackEnvKeys(updates);
51
99
  const raw = existsSync(path) ? readFileSync(path, "utf-8") : "";
52
100
  let merged = mergeEnvContent(raw, updates, { uncomment });
53
101
  if (!merged.endsWith("\n")) merged += "\n";
@@ -55,88 +103,45 @@ function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomm
55
103
  }
56
104
 
57
105
  function ensureSystemSecrets(state: ControlPlaneState): void {
58
- const systemEnvPath = `${state.stackDir}/stack.env`;
59
- const existing = existsSync(systemEnvPath) ? parseEnvFile(systemEnvPath) : {};
106
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
107
+ enforceVaultDirMode(dirname(systemEnvPath));
60
108
  const updates: Record<string, string> = {};
61
109
 
62
- // OP_UI_LOGIN_PASSWORD seeds the operator login secret. ensureSecrets
63
- // generates a random fallback the first time so the stack is never
64
- // installed with an empty password slot; the wizard / CLI install path
65
- // overwrites it with the operator's chosen value via
66
- // buildSystemSecretsFromSetup().
67
- if (!existing.OP_UI_LOGIN_PASSWORD) {
68
- updates.OP_UI_LOGIN_PASSWORD = randomBytes(32).toString("hex");
110
+ // Bootstrap only explicit host-provided overrides. Setup is allowed to be
111
+ // genuinely unconfigured until the wizard/CLI writes the chosen password.
112
+ if (process.env.OP_UI_LOGIN_PASSWORD) {
113
+ updates.OP_UI_LOGIN_PASSWORD = process.env.OP_UI_LOGIN_PASSWORD;
114
+ }
115
+ if (process.env.OP_OPENCODE_PASSWORD) {
116
+ updates.OP_OPENCODE_PASSWORD = process.env.OP_OPENCODE_PASSWORD;
69
117
  }
70
118
 
119
+ writeStackSecretEnv(state, updates);
120
+
71
121
  if (!existsSync(systemEnvPath)) {
72
- const header = [
73
- "# OpenPalm — Stack Configuration",
74
- "# All secrets and configuration live here. Advanced users may edit directly.",
75
- "",
76
- "# ── Authentication ──────────────────────────────────────────────────",
77
- "OP_UI_LOGIN_PASSWORD=",
78
- "",
79
- "# ── Service Auth ─────────────────────────────────────────────────────",
80
- "OP_OPENCODE_PASSWORD=",
81
- "",
82
- "# ── Provider API Keys ────────────────────────────────────────────────",
83
- "OPENAI_API_KEY=",
84
- "OPENAI_BASE_URL=",
85
- "ANTHROPIC_API_KEY=",
86
- "GROQ_API_KEY=",
87
- "MISTRAL_API_KEY=",
88
- "GOOGLE_API_KEY=",
89
- "MCP_API_KEY=",
90
- "EMBEDDING_API_KEY=",
91
- "LMSTUDIO_API_KEY=",
92
- "",
93
- "# ── Owner ────────────────────────────────────────────────────────────",
94
- `OP_OWNER_NAME=${process.env.OP_OWNER_NAME ?? ""}`,
95
- `OP_OWNER_EMAIL=${process.env.OP_OWNER_EMAIL ?? ""}`,
122
+ const header = [
123
+ "# OpenPalm — Stack Configuration",
124
+ "# Non-secret stack configuration only. File-based secrets live in knowledge/secrets/.",
125
+ "",
126
+ "# ── Authentication ──────────────────────────────────────────────────",
127
+ "OP_SETUP_COMPLETE=false",
96
128
  "",
97
129
  ].join("\n");
98
- const content = mergeEnvContent(header, updates);
99
- writeVaultFile(systemEnvPath, content.endsWith("\n") ? content : content + "\n");
130
+ writeVaultFile(systemEnvPath, header.endsWith("\n") ? header : header + "\n");
100
131
  return;
101
132
  }
102
-
103
- mergeVaultEnvFile(systemEnvPath, updates, true);
104
133
  }
105
134
 
106
135
  export function ensureSecrets(state: ControlPlaneState): void {
107
136
  enforceVaultDirMode(state.stackDir);
108
137
 
109
- // Migrate pre-0.11.0 installs (OP_UI_TOKEN/OP_ASSISTANT_TOKEN → OP_UI_LOGIN_PASSWORD)
110
- // before any code path that reads OP_UI_LOGIN_PASSWORD sees an empty value.
111
- migrateAuth0110(state);
112
-
113
138
  ensureSystemSecrets(state);
114
- ensureGuardianEnv(state.stackDir);
115
- ensureAuthJson(state.configDir);
139
+ ensureAuthJson(state);
116
140
  }
117
141
 
118
- /**
119
- * Ensure config/stack/guardian.env exists.
120
- * Channel HMAC secrets (CHANNEL_<NAME>_SECRET) live here exclusively.
121
- * This file is loaded by the guardian as an env_file and via GUARDIAN_SECRETS_PATH.
122
- */
123
- function ensureGuardianEnv(stackDir: string): void {
124
- const guardianEnvPath = `${stackDir}/guardian.env`;
125
- mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
126
- if (!existsSync(guardianEnvPath)) {
127
- writeVaultFile(guardianEnvPath, [
128
- "# Guardian channel HMAC secrets — managed by openpalm",
129
- "# Each enabled channel gets a CHANNEL_<NAME>_SECRET entry.",
130
- "",
131
- ].join("\n"));
132
- } else {
133
- try { chmodSync(guardianEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
134
- }
135
- }
136
-
137
- function ensureAuthJson(configDir: string): void {
138
- const authJsonPath = `${configDir}/auth.json`;
139
- mkdirSync(configDir, { recursive: true, mode: VAULT_DIR_MODE });
142
+ function ensureAuthJson(state: ControlPlaneState): void {
143
+ const authJsonPath = resolveAuthJsonPath(state);
144
+ mkdirSync(dirname(authJsonPath), { recursive: true, mode: VAULT_DIR_MODE });
140
145
 
141
146
  if (existsSync(authJsonPath)) {
142
147
  try {
@@ -162,22 +167,24 @@ export function updateSecretsEnv(
162
167
  state: ControlPlaneState,
163
168
  updates: Record<string, string>
164
169
  ): void {
165
- const stackEnvPath = `${state.stackDir}/stack.env`;
166
- if (!existsSync(stackEnvPath)) {
167
- throw new Error("config/stack/stack.env does not exist run setup first");
170
+ const secretUpdates: Record<string, string> = {};
171
+ const stackUpdates: Record<string, string> = {};
172
+ for (const [key, value] of Object.entries(updates)) {
173
+ if (SECRET_ENV_KEY_RE.test(key)) secretUpdates[key] = value;
174
+ else stackUpdates[key] = value;
168
175
  }
169
-
170
- mergeVaultEnvFile(stackEnvPath, updates, true);
176
+ writeStackSecretEnv(state, secretUpdates);
177
+ if (Object.keys(stackUpdates).length > 0) patchSecretsEnvFile(state.stackDir, stackUpdates);
171
178
  }
172
179
 
173
180
  /**
174
181
  * Merge-write provider API keys into OpenCode's auth.json at
175
- * `${configDir}/auth.json`. Each entry uses OpenCode's schema for
176
- * api-key auth: `{ <providerId>: { type: "api", key: "..." } }`.
182
+ * `${stackDir}/auth.json` (knowledge/secrets/auth.json). Each entry uses
183
+ * OpenCode's schema for api-key auth: `{ <providerId>: { type: "api", key } }`.
177
184
  *
178
- * This file is bind-mounted into the assistant container so the chat
179
- * assistant picks up new credentials on its next OpenCode restart —
180
- * see core.compose.yml.
185
+ * This file is bind-mounted into both the assistant and guardian containers
186
+ * so every OpenCode instance picks up new credentials on its next restart —
187
+ * see core.compose.yml (assistant) and channels.compose.yml (guardian).
181
188
  *
182
189
  * Existing entries (OAuth tokens, other providers) are preserved.
183
190
  * Empty values DELETE the corresponding entry.
@@ -188,8 +195,8 @@ export function writeAuthJsonProviderKeys(
188
195
  ): void {
189
196
  if (Object.keys(providerKeys).length === 0) return;
190
197
 
191
- const authJsonPath = `${state.configDir}/auth.json`;
192
- mkdirSync(state.configDir, { recursive: true, mode: VAULT_DIR_MODE });
198
+ const authJsonPath = resolveAuthJsonPath(state);
199
+ mkdirSync(dirname(authJsonPath), { recursive: true, mode: VAULT_DIR_MODE });
193
200
 
194
201
  let current: Record<string, unknown> = {};
195
202
  if (existsSync(authJsonPath)) {
@@ -227,16 +234,25 @@ export function writeAuthJsonProviderKeys(
227
234
  writeVaultFile(authJsonPath, JSON.stringify(current, null, 2) + "\n");
228
235
  }
229
236
 
230
- /** Read and parse config/stack/stack.env. Returns {} if the file does not exist. */
237
+ /** Read and parse knowledge/env/stack.env. Returns {} if the file does not exist. */
231
238
  export function readStackEnv(stackDir: string): Record<string, string> {
232
- return parseEnvFile(`${stackDir}/stack.env`);
239
+ const parsed = parseEnvFile(stackEnvPathFromStackDir(stackDir));
240
+ const nonSecret: Record<string, string> = {};
241
+ for (const [key, value] of Object.entries(parsed)) {
242
+ if (!isSecretLikeStackEnvKey(key)) nonSecret[key] = value;
243
+ }
244
+ return nonSecret;
245
+ }
246
+
247
+ export function readStackRuntimeEnv(stackDir: string): Record<string, string> {
248
+ return { ...readStackEnv(stackDir), ...readStackSecretEnv(stackDir) };
233
249
  }
234
250
 
235
251
  export function updateSystemSecretsEnv(
236
252
  state: ControlPlaneState,
237
253
  updates: Record<string, string>
238
254
  ): void {
239
- const systemEnvPath = `${state.stackDir}/stack.env`;
255
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
240
256
  enforceVaultDirMode(state.stackDir);
241
257
  if (!existsSync(systemEnvPath)) {
242
258
  ensureSystemSecrets(state);
@@ -250,9 +266,20 @@ export function patchSecretsEnvFile(
250
266
  ): void {
251
267
  if (Object.keys(patches).length === 0) return;
252
268
 
253
- const stackEnvPath = `${stackDir}/stack.env`;
254
- enforceVaultDirMode(stackDir);
255
- mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
269
+ const stackPatches: Record<string, string> = {};
270
+ const secretPatches: Record<string, string> = {};
271
+ for (const [key, value] of Object.entries(patches)) {
272
+ if (SECRET_ENV_KEY_RE.test(key)) secretPatches[key] = value;
273
+ else stackPatches[key] = value;
274
+ }
275
+ if (Object.keys(secretPatches).length > 0) {
276
+ writeStackSecretEnv({ stackDir, homeDir: '', configDir: '', stashDir: '', workspaceDir: '', dataDir: '', services: {}, artifacts: { compose: '' }, artifactMeta: [] }, secretPatches);
277
+ }
278
+ if (Object.keys(stackPatches).length === 0) return;
279
+ assertNoSecretLikeStackEnvKeys(stackPatches);
280
+
281
+ const stackEnvPath = stackEnvPathFromStackDir(stackDir);
282
+ enforceVaultDirMode(dirname(stackEnvPath));
256
283
 
257
284
  let existingContent = "";
258
285
  try {
@@ -263,7 +290,7 @@ export function patchSecretsEnvFile(
263
290
  // start fresh
264
291
  }
265
292
 
266
- let result = mergeEnvContent(existingContent, patches);
293
+ let result = mergeEnvContent(existingContent, stackPatches);
267
294
  if (!result.endsWith("\n")) result += "\n";
268
295
  writeVaultFile(stackEnvPath, result);
269
296
  }
@@ -35,7 +35,7 @@
35
35
  "properties": {
36
36
  "uiLoginPassword": {
37
37
  "type": "string",
38
- "description": "Operator login password for the OpenPalm UI. Persisted to stack.env as OP_UI_LOGIN_PASSWORD; the UI's op_session cookie value is compared against it on every authenticated request.",
38
+ "description": "Operator login password for the OpenPalm UI. Persisted as knowledge/secrets/op_ui_login_password; the UI's op_session cookie value is compared against it on every authenticated request.",
39
39
  "minLength": 8
40
40
  }
41
41
  }
@@ -1,18 +1,13 @@
1
1
  import { parseEnvFile } from './env.js';
2
+ import { stackEnvPathFromStackDir } from './paths.js';
2
3
 
3
4
  /**
4
- * Check if setup is complete by reading config/stack/stack.env.
5
+ * Check if setup is complete by reading knowledge/env/stack.env.
5
6
  *
6
- * Phase 4 of the auth/proxy refactor replaced the legacy `OP_UI_TOKEN`
7
- * sentinel with `OP_UI_LOGIN_PASSWORD`. The presence of a non-empty value
8
- * implies the operator (or the install wizard) has seeded the login
9
- * secret; `OP_SETUP_COMPLETE=true` is still authoritative when present.
7
+ * Only OP_SETUP_COMPLETE=true is authoritative. Secrets live in
8
+ * knowledge/secrets and are not completion sentinels.
10
9
  */
11
10
  export function isSetupComplete(stackDir: string): boolean {
12
- const parsed = parseEnvFile(`${stackDir}/stack.env`);
13
- if ("OP_SETUP_COMPLETE" in parsed) {
14
- return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
15
- }
16
-
17
- return (parsed.OP_UI_LOGIN_PASSWORD ?? "").length > 0;
11
+ const parsed = parseEnvFile(stackEnvPathFromStackDir(stackDir));
12
+ return parsed.OP_SETUP_COMPLETE === "true";
18
13
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from "./setup.js";
12
12
  import type { SetupSpec, SetupConnection } from "./setup.js";
13
13
  import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
14
+ import { readSecret } from './secrets-files.js';
14
15
 
15
16
  // ── Helpers ──────────────────────────────────────────────────────────────
16
17
 
@@ -38,15 +39,15 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
38
39
  function seedRequiredAssets(homeDir: string): void {
39
40
  mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
40
41
  writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
41
- mkdirSync(join(homeDir, "state", "assistant"), { recursive: true });
42
- writeFileSync(join(homeDir, "state", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
43
- writeFileSync(join(homeDir, "state", "assistant", "AGENTS.md"), "# Agents\n");
44
- mkdirSync(join(homeDir, "state"), { recursive: true });
45
- // Automations live in state/registry/automations (shipped catalog) and stash/tasks (user tasks)
46
- mkdirSync(join(homeDir, "state", "registry", "automations"), { recursive: true });
47
- writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-logs.md"), "---\nschedule: \"0 4 * * 0\"\ndescription: cleanup logs\n---\n");
48
- writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-data.md"), "---\nschedule: \"0 5 * * 0\"\ndescription: cleanup data\n---\n");
49
- writeFileSync(join(homeDir, "state", "registry", "automations", "validate-config.md"), "---\nschedule: \"0 3 * * *\"\ndescription: validate config\n---\n");
42
+ mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
43
+ writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
44
+ writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
45
+ mkdirSync(join(homeDir, "data"), { recursive: true });
46
+ // Automations live in knowledge/tasks as AKM-owned task files.
47
+ mkdirSync(join(homeDir, "data", "registry", "automations"), { recursive: true });
48
+ writeFileSync(join(homeDir, "data", "registry", "automations", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
49
+ writeFileSync(join(homeDir, "data", "registry", "automations", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
50
+ writeFileSync(join(homeDir, "data", "registry", "automations", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
50
51
  }
51
52
 
52
53
  // ── Tests: validateSetupSpec ────────────────────────────────────────────
@@ -204,7 +205,6 @@ describe("buildSecretsFromSetup", () => {
204
205
  const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
205
206
  expect(secrets.OP_UI_LOGIN_PASSWORD).toBeUndefined();
206
207
  expect(secrets.OP_UI_TOKEN).toBeUndefined();
207
- expect(secrets.ADMIN_TOKEN).toBeUndefined();
208
208
  });
209
209
 
210
210
  it("does not include SYSTEM_LLM_* in user secrets", () => {
@@ -305,10 +305,7 @@ describe("buildAuthJsonFromSetup", () => {
305
305
  });
306
306
 
307
307
  describe("buildSystemSecretsFromSetup", () => {
308
- // Phase 4: assistant token was removed; the only stack.env secret this
309
- // helper writes now is OP_UI_LOGIN_PASSWORD. OP_OPENCODE_PASSWORD is
310
- // generated by ensureSystemSecrets() and persists across reruns.
311
- it("returns OP_UI_LOGIN_PASSWORD equal to the supplied operator password", () => {
308
+ it("returns the file-based UI login password update", () => {
312
309
  const secrets = buildSystemSecretsFromSetup("test-admin-token-12345");
313
310
  expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
314
311
  expect(secrets.OP_UI_TOKEN).toBeUndefined();
@@ -321,7 +318,7 @@ describe("buildSystemSecretsFromSetup", () => {
321
318
  describe("performSetup", () => {
322
319
  let homeDir: string;
323
320
  let configDir: string;
324
- let stateDir: string;
321
+ let dataDir: string;
325
322
  let stackDir: string;
326
323
 
327
324
  const savedEnv: Record<string, string | undefined> = {};
@@ -329,44 +326,41 @@ describe("performSetup", () => {
329
326
  beforeEach(() => {
330
327
  homeDir = mkdtempSync(join(tmpdir(), "openpalm-setup-"));
331
328
  configDir = join(homeDir, "config");
332
- stateDir = join(homeDir, "state");
329
+ dataDir = join(homeDir, "data");
333
330
  stackDir = join(configDir, "stack");
334
331
 
335
332
  // Create required directory structure
336
333
  for (const dir of [
337
334
  homeDir,
338
335
  configDir,
339
- join(homeDir, "state", "registry", "automations"),
336
+ join(homeDir, "data", "registry", "automations"),
340
337
  join(configDir, "assistant"),
341
338
  join(configDir, "akm"),
342
339
  stackDir,
343
340
  join(stackDir, "addons"),
344
- join(homeDir, "stash"),
341
+ join(homeDir, "knowledge"),
342
+ join(homeDir, "knowledge", "env"),
343
+ join(homeDir, "knowledge", "secrets"),
345
344
  join(homeDir, "workspace"),
346
- join(homeDir, "cache"),
347
- join(homeDir, "cache", "akm"),
348
- stateDir,
349
- join(stateDir, "assistant"),
350
- join(stateDir, "admin"),
351
- join(stateDir, "guardian"),
352
- join(stateDir, "logs"),
353
- join(stateDir, "logs", "opencode"),
345
+ dataDir,
346
+ join(dataDir, "assistant"),
347
+ join(dataDir, "admin"),
348
+ join(dataDir, "guardian"),
349
+ join(dataDir, "akm", "cache"),
350
+ join(dataDir, "akm", "data"),
351
+ join(dataDir, "logs"),
352
+ join(dataDir, "backups"),
353
+ join(dataDir, "rollback"),
354
354
  ]) {
355
355
  mkdirSync(dir, { recursive: true });
356
356
  }
357
357
 
358
358
  // Create stub stack.env so isSetupComplete doesn't crash
359
359
  writeFileSync(
360
- join(stackDir, "stack.env"),
360
+ join(homeDir, "knowledge", "env", "stack.env"),
361
361
  [
362
362
  "OP_SETUP_COMPLETE=false",
363
- "OP_UI_LOGIN_PASSWORD=",
364
- "OPENAI_API_KEY=",
365
363
  "OPENAI_BASE_URL=",
366
- "ANTHROPIC_API_KEY=",
367
- "GROQ_API_KEY=",
368
- "MISTRAL_API_KEY=",
369
- "GOOGLE_API_KEY=",
370
364
  "OP_OWNER_NAME=",
371
365
  "OP_OWNER_EMAIL=",
372
366
  "",
@@ -394,12 +388,11 @@ describe("performSetup", () => {
394
388
  expect(result.error).toBeDefined();
395
389
  });
396
390
 
397
- it("writes stack.env with the UI login password", async () => {
391
+ it("writes the UI login password to knowledge/secrets", async () => {
398
392
  const result = await performSetup(makeValidSpec());
399
393
  expect(result.ok).toBe(true);
400
394
 
401
- const secretsContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
402
- expect(secretsContent).toContain("test-admin-token-12345");
395
+ expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
403
396
  });
404
397
 
405
398
  it("writes akm config.json with llm and embedding", async () => {
@@ -479,9 +472,16 @@ describe("performSetup", () => {
479
472
  const spec = readStackSpec(stackDir);
480
473
  expect(spec).not.toBeNull();
481
474
  expect(spec!.version).toBe(2);
475
+
476
+ const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
477
+ expect(stackEnv).not.toContain('OPENAI_API_KEY=');
478
+ expect(readSecret(stackDir, 'openai_api_key')).toBeNull();
479
+
480
+ const authJson = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), 'utf-8')) as Record<string, { key: string }>;
481
+ expect(authJson.openai.key).toBe('sk-secondary');
482
482
  });
483
483
 
484
- it("writes channel credentials to stack.env when channelCredentials provided", async () => {
484
+ it("splits channel credentials between secret files and stack.env", async () => {
485
485
  const input = makeValidSpec({
486
486
  channelCredentials: {
487
487
  discord: {
@@ -494,9 +494,11 @@ describe("performSetup", () => {
494
494
  const result = await performSetup(input);
495
495
  expect(result.ok).toBe(true);
496
496
 
497
- const stackEnvContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
498
- expect(stackEnvContent).toContain("discord-bot-token-xyz");
499
- expect(stackEnvContent).toContain("discord-app-id-123");
497
+ expect(readSecret(stackDir, 'discord_bot_token')).toBe("discord-bot-token-xyz\n");
498
+ expect(readSecret(stackDir, 'discord_application_id')).toBeNull();
499
+ const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
500
+ expect(stackEnv).toContain('DISCORD_APPLICATION_ID=discord-app-id-123');
501
+ expect(stackEnv).not.toContain('DISCORD_BOT_TOKEN=');
500
502
  });
501
503
 
502
504
  it("ensureOpenCodeConfig never writes forbidden keys (providers, smallModel, model) to the user config", async () => {