@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
@@ -0,0 +1,113 @@
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join, dirname, basename } from 'node:path';
3
+
4
+ const SECRET_NAME_RE = /^[a-z0-9][a-z0-9_]{0,80}$/;
5
+ const SECRETS_DIR_MODE = 0o700;
6
+ const SECRET_FILE_MODE = 0o600;
7
+
8
+ export function validateSecretName(name: string): void {
9
+ if (!SECRET_NAME_RE.test(name)) throw new Error(`Invalid secret name: ${name}`);
10
+ }
11
+
12
+ function resolveHomeDirFromStackDir(stackDir: string): string {
13
+ const parentDir = dirname(stackDir);
14
+ if (basename(stackDir) === 'stack' && basename(parentDir) === 'config') {
15
+ return dirname(parentDir);
16
+ }
17
+ return stackDir;
18
+ }
19
+
20
+ export function resolveSecretsDir(stackDir: string): string {
21
+ const dir = join(resolveHomeDirFromStackDir(stackDir), 'knowledge', 'secrets');
22
+ mkdirSync(dir, { recursive: true, mode: SECRETS_DIR_MODE });
23
+ chmodSync(dir, SECRETS_DIR_MODE);
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ if (entry.isFile()) chmodSync(join(dir, entry.name), SECRET_FILE_MODE);
26
+ }
27
+ return dir;
28
+ }
29
+
30
+ export function secretPath(stackDir: string, name: string): string {
31
+ validateSecretName(name);
32
+ return join(resolveSecretsDir(stackDir), name);
33
+ }
34
+
35
+ export function readSecret(stackDir: string, name: string): string | null {
36
+ const path = secretPath(stackDir, name);
37
+ if (!existsSync(path)) return null;
38
+ chmodSync(path, SECRET_FILE_MODE);
39
+ return readFileSync(path, 'utf-8');
40
+ }
41
+
42
+ export function writeSecret(stackDir: string, name: string, value: string): void {
43
+ const path = secretPath(stackDir, name);
44
+ writeFileSync(path, value, { mode: SECRET_FILE_MODE });
45
+ chmodSync(path, SECRET_FILE_MODE);
46
+ }
47
+
48
+ export function ensureSecret(stackDir: string, name: string, valueFactory: () => string): string {
49
+ const existing = readSecret(stackDir, name);
50
+ if (existing !== null) return existing;
51
+ const value = valueFactory();
52
+ writeSecret(stackDir, name, value);
53
+ return value;
54
+ }
55
+
56
+ export function removeSecret(stackDir: string, name: string): void {
57
+ rmSync(secretPath(stackDir, name), { force: true });
58
+ }
59
+
60
+ export function listSecretNames(stackDir: string): string[] {
61
+ const dir = resolveSecretsDir(stackDir);
62
+ return readdirSync(dir, { withFileTypes: true })
63
+ .filter((entry) => entry.isFile() && SECRET_NAME_RE.test(entry.name))
64
+ .map((entry) => entry.name)
65
+ .sort();
66
+ }
67
+
68
+ // ── Raw file access for the Secrets admin tab ──────────────────────────────
69
+ // The admin Secrets tab is a plain file browser/editor for the secrets dir, so
70
+ // it must reach files the strict SECRET_NAME_RE excludes (e.g. `auth.json`). The
71
+ // filename guard below permits dots/dashes but is still traversal-safe (no path
72
+ // separators, no `..`). Names are always basenames within the secrets dir.
73
+ const SECRET_FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
74
+
75
+ export function assertSafeSecretFilename(name: string): void {
76
+ if (!SECRET_FILENAME_RE.test(name) || name.includes('..')) {
77
+ throw new Error(`Invalid secret file name: ${name}`);
78
+ }
79
+ }
80
+
81
+ export type SecretFileInfo = { name: string; size: number };
82
+
83
+ /** List every regular file in the secrets dir (incl. auth.json), with byte size. */
84
+ export function listSecretFiles(stackDir: string): SecretFileInfo[] {
85
+ const dir = resolveSecretsDir(stackDir);
86
+ return readdirSync(dir, { withFileTypes: true })
87
+ .filter((entry) => entry.isFile() && SECRET_FILENAME_RE.test(entry.name) && !entry.name.includes('..'))
88
+ .map((entry) => ({ name: entry.name, size: statSync(join(dir, entry.name)).size }))
89
+ .sort((a, b) => a.name.localeCompare(b.name));
90
+ }
91
+
92
+ /** Read a secrets-dir file by basename (raw contents), or null if absent. */
93
+ export function readSecretFile(stackDir: string, name: string): string | null {
94
+ assertSafeSecretFilename(name);
95
+ const path = join(resolveSecretsDir(stackDir), name);
96
+ if (!existsSync(path)) return null;
97
+ chmodSync(path, SECRET_FILE_MODE);
98
+ return readFileSync(path, 'utf-8');
99
+ }
100
+
101
+ /** Write a secrets-dir file by basename (0600). */
102
+ export function writeSecretFile(stackDir: string, name: string, value: string): void {
103
+ assertSafeSecretFilename(name);
104
+ const path = join(resolveSecretsDir(stackDir), name);
105
+ writeFileSync(path, value, { mode: SECRET_FILE_MODE });
106
+ chmodSync(path, SECRET_FILE_MODE);
107
+ }
108
+
109
+ /** Delete a secrets-dir file by basename. */
110
+ export function removeSecretFile(stackDir: string, name: string): void {
111
+ assertSafeSecretFilename(name);
112
+ rmSync(join(resolveSecretsDir(stackDir), name), { force: true });
113
+ }
@@ -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
  }