@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
@@ -5,49 +5,159 @@
5
5
  * construction into a single shared module. Both CLI and admin
6
6
  * routes use these functions instead of assembling args inline.
7
7
  */
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
9
9
  import type { ControlPlaneState } from "./types.js";
10
10
  import { buildComposeFileList } from "./lifecycle.js";
11
11
  import { buildEnvFiles } from "./config-persistence.js";
12
12
  import { resolveComposeProjectName } from "./docker.js";
13
+ import { parseEnvFile } from "./env.js";
14
+ import { canonicalAddonProfileSelection } from "./profile-ids.js";
15
+ import { listStackSpecAddons } from "./stack-spec.js";
13
16
 
14
17
  // ── Types ────────────────────────────────────────────────────────────────
15
18
 
16
19
  export type ComposeOptions = {
17
20
  files: string[];
18
21
  envFiles: string[];
22
+ profiles: string[];
19
23
  };
20
24
 
25
+ // ── Profile Resolution ───────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Resolve active Docker Compose profiles from the stack env.
29
+ * Reads OP_VOICE_PROFILE and OP_OLLAMA_PROFILE (addon hardware profiles).
30
+ * Returns deduplicated, non-empty profile strings.
31
+ */
32
+ export function resolveActiveProfiles(state: ControlPlaneState): string[] {
33
+ const profiles: string[] = [];
34
+ const stackEnvPath = `${state.stashDir}/env/stack.env`;
35
+ let env: Record<string, string> = {};
36
+ if (existsSync(stackEnvPath)) {
37
+ env = parseEnvFile(stackEnvPath);
38
+ for (const profile of (env.COMPOSE_PROFILES ?? '').split(',')) {
39
+ const trimmed = profile.trim();
40
+ if (trimmed) profiles.push(trimmed);
41
+ }
42
+ const voiceProfile = canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '');
43
+ if (voiceProfile) profiles.push(voiceProfile);
44
+ const ollamaProfile = canonicalAddonProfileSelection('ollama', env.OP_OLLAMA_PROFILE ?? '');
45
+ if (ollamaProfile) profiles.push(ollamaProfile);
46
+ }
47
+
48
+ for (const addon of listStackSpecAddons(state.stackDir)) {
49
+ if (addon === 'voice') {
50
+ profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
51
+ } else if (addon === 'ollama') {
52
+ profiles.push(canonicalAddonProfileSelection('ollama', env.OP_OLLAMA_PROFILE ?? '') || 'addon.ollama.cpu');
53
+ } else {
54
+ profiles.push(`addon.${addon}`);
55
+ }
56
+ }
57
+
58
+ return [...new Set(profiles)];
59
+ }
60
+
21
61
  // ── Builders ─────────────────────────────────────────────────────────────
22
62
 
23
63
  /**
24
- * Build the compose file and env file lists for a given state.
25
- * Returns the resolved files and env files for use with docker.ts functions.
26
- *
27
- * Note: env files are already filtered to only existing paths by
28
- * `buildEnvFiles()` in config-persistence.ts.
64
+ * Build the compose file, env file, and profile lists for a given state.
65
+ * Returns the resolved values for use with docker.ts functions.
29
66
  */
30
67
  export function buildComposeOptions(state: ControlPlaneState): ComposeOptions {
31
68
  return {
32
69
  files: buildComposeFileList(state),
33
70
  envFiles: buildEnvFiles(state),
71
+ profiles: resolveActiveProfiles(state),
34
72
  };
35
73
  }
36
74
 
37
75
  /**
38
76
  * Build the full docker compose CLI argument array for a given state.
39
77
  *
40
- * Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, ...]
78
+ * Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, '--profile', addon.voice.cpu, ...]
41
79
  *
42
80
  * Only includes env files that exist on disk.
43
81
  */
44
82
  export function buildComposeCliArgs(state: ControlPlaneState): string[] {
45
- const { files, envFiles } = buildComposeOptions(state);
83
+ const { files, envFiles, profiles } = buildComposeOptions(state);
46
84
 
47
85
  return [
48
86
  "--project-name",
49
- resolveComposeProjectName(),
87
+ resolveComposeProjectName(collectEnvOverrides(envFiles)),
50
88
  ...files.flatMap((f) => ["-f", f]),
51
89
  ...envFiles.filter((f) => existsSync(f)).flatMap((f) => ["--env-file", f]),
90
+ ...profiles.flatMap((p) => ["--profile", p]),
91
+ ];
92
+ }
93
+
94
+ // ── Run Script ───────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Convert an absolute path to a ${OP_HOME}-relative shell expression.
98
+ * E.g. "/home/user/.openpalm/config/stack/core.compose.yml"
99
+ * → "${OP_HOME}/config/stack/core.compose.yml"
100
+ */
101
+ function toOpHomeRelative(absPath: string, homeDir: string): string {
102
+ if (absPath.startsWith(homeDir)) {
103
+ return absPath.replace(homeDir, "${OP_HOME}");
104
+ }
105
+ return absPath;
106
+ }
107
+
108
+ function shellArg(value: string): string {
109
+ if (value.includes("${")) return `"${value}"`;
110
+ return `'${value.replace(/'/g, `'\\''`)}'`;
111
+ }
112
+
113
+ function collectEnvOverrides(envFiles: string[]): Record<string, string> {
114
+ const overrides: Record<string, string> = {};
115
+ for (const envFile of envFiles) {
116
+ Object.assign(overrides, parseEnvFile(envFile));
117
+ }
118
+ return overrides;
119
+ }
120
+
121
+ /**
122
+ * Write the effective docker compose command to OP_HOME/run.sh.
123
+ * Uses environment variable references (${OP_HOME}, ${OP_PROJECT_NAME}) so
124
+ * the script is portable and self-documenting.
125
+ *
126
+ * Wraps `source stack.env` in `set -a` / `set +a` so variables without
127
+ * explicit `export` are still available to child processes (docker compose).
128
+ *
129
+ * Called after any stack modification: setup, addon enable/disable,
130
+ * profile change, or upgrade.
131
+ */
132
+ export function writeRunScript(state: ControlPlaneState): void {
133
+ const { files, envFiles, profiles } = buildComposeOptions(state);
134
+ const stackEnvRef = toOpHomeRelative(`${state.stashDir}/env/stack.env`, state.homeDir);
135
+
136
+ const lines: string[] = [
137
+ "#!/usr/bin/env bash",
138
+ "# Auto-generated by OpenPalm - DO NOT EDIT",
139
+ "# This file reproduces the exact docker compose invocation",
140
+ "# used by the control plane. Run it to start/restart the stack.",
141
+ "",
142
+ `set -euo pipefail`,
143
+ `SCRIPT_DIR="$(cd -- "$(dirname -- "${"${BASH_SOURCE[0]}"}")" && pwd)"`,
144
+ `OP_HOME="${"${OP_HOME:-$SCRIPT_DIR}"}"`,
145
+ `export OP_HOME`,
146
+ `set -a`,
147
+ `source ${shellArg(stackEnvRef)}`,
148
+ `set +a`,
149
+ `profile_args=(${profiles.map(shellArg).join(' ')})`,
150
+ `docker compose --project-name "${"${OP_PROJECT_NAME:-${COMPOSE_PROJECT_NAME:-openpalm}}"}" \\`,
151
+ ` "${"${profile_args[@]}"}" \\`,
152
+ ...files.flatMap((f) => [` -f ${shellArg(toOpHomeRelative(f, state.homeDir))} \\`]),
153
+ ...envFiles.filter((f) => existsSync(f)).flatMap((f) => [` --env-file ${shellArg(toOpHomeRelative(f, state.homeDir))} \\`]),
154
+ ` up -d`,
155
+ "",
52
156
  ];
157
+
158
+ const content = lines.join("\n");
159
+ const runScriptPath = `${state.homeDir}/run.sh`;
160
+ mkdirSync(state.homeDir, { recursive: true });
161
+ writeFileSync(runScriptPath, content, { mode: 0o755 });
162
+ chmodSync(runScriptPath, 0o755);
53
163
  }
@@ -3,58 +3,52 @@
3
3
  *
4
4
  * Writes and derives live runtime files (compose, env, schemas).
5
5
  * Files are validated in-place before writing; rollback is handled by
6
- * the rollback module (snapshot to ~/.cache/openpalm/rollback/).
6
+ * the rollback module (snapshot to OP_HOME/data/rollback/).
7
7
  */
8
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
9
- import { dirname } from "node:path";
8
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
9
+ import { dirname, resolve as resolvePath } from "node:path";
10
10
  import { parse as yamlParse } from "yaml";
11
- import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
11
+ import { parseEnvContent, parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
12
+ import { assertNoSecretLikeStackEnvKeys, isSecretLikeStackEnvKey } from './secrets.js';
13
+ import { ensureSecret } from './secrets-files.js';
12
14
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
13
- import { isChannelAddon } from "./channels.js";
14
15
  import { listEnabledAddonIds } from "./registry.js";
15
16
  import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
16
17
 
17
18
  import {
18
19
  readCoreCompose,
20
+ readBundledStackAsset,
19
21
  } from "./core-assets.js";
20
22
  export { sha256, randomHex } from "./crypto.js";
21
23
  import { sha256, randomHex } from "./crypto.js";
22
24
 
23
- const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
25
+ const DEFAULT_IMAGE_TAG = "latest";
24
26
 
25
27
  // ── Env File Management ──────────────────────────────────────────────
26
28
 
27
29
  /**
28
30
  * Return the env files used for docker compose --env-file args.
29
- * These are the live vault env files.
30
31
  *
31
- * Order: stack.env -> guardian.env
32
- *
33
- * Note: `vault/user/user.env` is no longer a
34
- * compose env_file. User-managed env secrets live in the akm
35
- * `vault:user` store and are sourced by the assistant entrypoint at
36
- * container startup. The legacy file is migrated into akm and deleted
37
- * on upgrade; subsequent `docker compose` invocations must not reference
38
- * it (compose interpolates `${VAR}` against the merged --env-file
39
- * contents, and a stale user.env would shadow the akm-sourced values).
32
+ * Only `knowledge/env/stack.env` (non-secret system config). Secret values
33
+ * live in `knowledge/secrets/<ENV_KEY>` and are granted to services as Compose
34
+ * file secrets. The user env (`knowledge/env/user.env`) is NOT a compose
35
+ * env_file it is sourced by the assistant entrypoint at container startup.
40
36
  */
41
37
  export function buildEnvFiles(state: ControlPlaneState): string[] {
42
38
  return [
43
- `${state.stackDir}/stack.env`,
44
- `${state.stackDir}/guardian.env`,
39
+ `${state.stashDir}/env/stack.env`,
45
40
  ].filter(existsSync);
46
41
  }
47
42
 
48
43
  /**
49
- * Write system-managed values to config/stack/stack.env.
44
+ * Write system-managed values to knowledge/env/stack.env.
50
45
  *
51
- * Channel HMAC secrets are NOT written here — they belong in guardian.env.
52
- * Use writeChannelSecrets() for channel secrets.
46
+ * Secret-like keys are NOT written here — they belong in knowledge/secrets/.
47
+ * Use ensureChannelSecret() for channel secrets.
53
48
  */
54
49
  export function writeSystemEnv(state: ControlPlaneState): void {
55
- mkdirSync(state.stackDir, { recursive: true });
56
-
57
- const systemEnvPath = `${state.stackDir}/stack.env`;
50
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
51
+ mkdirSync(`${state.stashDir}/env`, { recursive: true, mode: 0o700 });
58
52
 
59
53
  let base = "";
60
54
  if (existsSync(systemEnvPath)) {
@@ -63,11 +57,12 @@ export function writeSystemEnv(state: ControlPlaneState): void {
63
57
  base = generateFallbackSystemEnv(state);
64
58
  }
65
59
 
66
- // Preserve existing OP_SETUP_COMPLETE=true
67
- const alreadyComplete = /^OP_SETUP_COMPLETE=true$/mi.test(base);
68
-
60
+ // Preserve the existing OP_SETUP_COMPLETE flag as-is.
61
+ // Only the wizard completion path (buildSystemSecretsFromSetup) writes "true".
62
+ // Defaulting to "false" here ensures a fresh install always shows the wizard.
63
+ const parsed = parseEnvFile(systemEnvPath);
69
64
  const adminManaged: Record<string, string> = {
70
- OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
65
+ OP_SETUP_COMPLETE: parsed.OP_SETUP_COMPLETE === "true" ? "true" : "false",
71
66
  };
72
67
 
73
68
  // Backfill OP_UID/OP_GID when the existing stack.env was written by an
@@ -76,13 +71,20 @@ export function writeSystemEnv(state: ControlPlaneState): void {
76
71
  // missing or zero — an operator who manually set OP_UID=2000 (e.g.
77
72
  // because they're running on a host with a non-1000 service account)
78
73
  // must not be silently changed.
79
- const parsed = parseEnvFile(systemEnvPath);
80
74
  const ids = resolveOperatorIds(state.homeDir);
81
75
  if (ids) {
82
76
  if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
83
77
  if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
84
78
  }
85
79
 
80
+ // Backfill OP_HOME when missing — compose files reference ${OP_HOME}
81
+ // for all volume mounts. Without this, Docker Compose defaults to blank.
82
+ if (!parsed.OP_HOME) adminManaged.OP_HOME = state.homeDir;
83
+
84
+ base = stripSecretLikeEnvKeys(base);
85
+ assertNoSecretLikeStackEnvKeys(parseEnvContent(base));
86
+ assertNoSecretLikeStackEnvKeys(adminManaged);
87
+
86
88
  const content = mergeEnvContent(base, adminManaged, {
87
89
  sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
88
90
  });
@@ -91,6 +93,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
91
93
  chmodSync(systemEnvPath, 0o600);
92
94
  }
93
95
 
96
+ function stripSecretLikeEnvKeys(content: string): string {
97
+ return content
98
+ .split('\n')
99
+ .filter((line) => {
100
+ let trimmed = line.trim();
101
+ if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7).trimStart();
102
+ const eq = trimmed.indexOf('=');
103
+ if (eq <= 0) return true;
104
+ return !isSecretLikeStackEnvKey(trimmed.slice(0, eq).trim());
105
+ })
106
+ .join('\n');
107
+ }
108
+
94
109
  function generateFallbackSystemEnv(state: ControlPlaneState): string {
95
110
  // Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
96
111
  // Skipped on Windows where containers run in WSL2 and OP_UID has no
@@ -104,12 +119,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
104
119
  "# OpenPalm — System Configuration (managed by CLI/admin)",
105
120
  "# Auto-generated fallback.",
106
121
  "",
107
- "# ── Authentication ──────────────────────────────────────────────────",
108
- `OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
109
- "",
110
- "# ── Service Auth ─────────────────────────────────────────────────────",
111
- "OP_OPENCODE_PASSWORD=",
112
- "",
113
122
  "# ── Paths ──────────────────────────────────────────────────────────",
114
123
  `OP_HOME=${state.homeDir}`,
115
124
  ...idLines,
@@ -131,37 +140,20 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
131
140
  // ── Stack Overlay Discovery ────────────────────────────────────────────
132
141
 
133
142
  /**
134
- * Discover compose overlays from the stack directory.
135
- * Returns full paths: [stack/core.compose.yml, stack/addons/{name}/compose.yml].
143
+ * Discover active compose overlays.
144
+ * Returns the fixed compose stack: core, services, channels, and custom.
145
+ * First-party services are profile-gated inside services.compose.yml and
146
+ * channels.compose.yml.
136
147
  */
137
- export function discoverStackOverlays(stackDir: string): string[] {
148
+ export function discoverStackOverlays(stackDir: string, _homeDir?: string): string[] {
138
149
  const files: string[] = [];
139
150
 
140
151
  const coreYml = `${stackDir}/core.compose.yml`;
141
152
  if (existsSync(coreYml)) files.push(coreYml);
142
153
 
143
- const addonsDir = `${stackDir}/addons`;
144
- if (existsSync(addonsDir)) {
145
- const entries = readdirSync(addonsDir, { withFileTypes: true })
146
- .filter((e) => e.isDirectory())
147
- .sort((a, b) => a.name.localeCompare(b.name));
148
- for (const entry of entries) {
149
- const dir = `${addonsDir}/${entry.name}`;
150
- // Pick up compose.yml plus any compose.<variant>.yml sibling
151
- // overlays (e.g. compose.cdi.yml generated by /admin/voice on
152
- // CDI hosts). Stable sort: compose.yml first, then siblings
153
- // alphabetically, so the base file's keys are the defaults and
154
- // overlays merge on top in deterministic order.
155
- const overlays = readdirSync(dir, { withFileTypes: true })
156
- .filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
157
- .map((e) => e.name)
158
- .sort((a, b) => {
159
- if (a === "compose.yml" || a === "compose.yaml") return -1;
160
- if (b === "compose.yml" || b === "compose.yaml") return 1;
161
- return a.localeCompare(b);
162
- });
163
- for (const name of overlays) files.push(`${dir}/${name}`);
164
- }
154
+ for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
155
+ const composePath = `${stackDir}/${name}`;
156
+ if (existsSync(composePath)) files.push(composePath);
165
157
  }
166
158
 
167
159
  return files;
@@ -192,79 +184,38 @@ export function buildRuntimeFileMeta(artifacts: {
192
184
  }
193
185
 
194
186
  // ── Channel Secrets ────────────────────────────────────────────────────
195
- // Channel HMAC secrets live exclusively in vault/stack/guardian.env.
196
-
197
- const CHANNEL_SECRET_RE = /^CHANNEL_([A-Z0-9_]+)_SECRET$/;
198
187
 
199
- /** Extract channel secrets from parsed env entries. */
200
- function extractChannelSecrets(parsed: Record<string, string>): Record<string, string> {
201
- const result: Record<string, string> = {};
202
- for (const [key, value] of Object.entries(parsed)) {
203
- const match = key.match(CHANNEL_SECRET_RE);
204
- if (match?.[1] && value) result[match[1].toLowerCase()] = value;
205
- }
206
- return result;
188
+ export function channelSecretName(addon: string): string {
189
+ return `channel_${addon.replace(/-/g, '_')}_secret`;
207
190
  }
208
191
 
209
- /**
210
- * Read channel HMAC secrets from config/stack/guardian.env.
211
- */
212
- export function readChannelSecrets(stackDir: string): Record<string, string> {
213
- return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
214
- }
215
-
216
- /**
217
- * Write channel HMAC secrets to state/guardian.env.
218
- * Merges with existing content; does not overwrite unrelated entries.
219
- */
220
- export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
221
- const guardianPath = `${stackDir}/guardian.env`;
222
- mkdirSync(stackDir, { recursive: true });
223
-
224
- let base = "";
225
- if (existsSync(guardianPath)) {
226
- base = readFileSync(guardianPath, "utf-8");
227
- } else {
228
- base = "# Guardian channel HMAC secrets — managed by openpalm\n";
229
- }
230
-
231
- const updates: Record<string, string> = {};
232
- for (const [ch, secret] of Object.entries(secrets)) {
233
- updates[`CHANNEL_${ch.toUpperCase()}_SECRET`] = secret;
234
- }
235
-
236
- const content = mergeEnvContent(base, updates);
237
- writeFileSync(guardianPath, content, { mode: 0o600 });
238
- // Ensure correct permissions even if file already existed with wrong mode
239
- chmodSync(guardianPath, 0o600);
192
+ export function ensureChannelSecret(stackDir: string, addon: string): string {
193
+ return ensureSecret(stackDir, channelSecretName(addon), () => randomHex(16));
240
194
  }
241
195
 
242
196
  // ── Volume Mount Targets ───────────────────────────────────────────────
243
197
 
244
198
  /**
245
- * Parse all enabled compose files and pre-create every host-side volume
246
- * mount target as the current user. This prevents Docker from creating
247
- * them as root-owned, which causes EACCES inside non-root containers.
248
- *
249
- * For file mounts (basename contains a `.`), creates an empty file.
250
- * For directory mounts (basename has no `.`), creates the directory.
251
- *
252
- * Heuristic: a basename containing a `.` is treated as a file. This
253
- * intentionally includes leading-dot files (e.g. `.env`) because Docker
254
- * bind mounts to them must be regular files. Bare directory names like
255
- * `stack` or `addons` lack extensions and are created as directories.
199
+ * Parse enabled compose files and pre-create host-side volume mount
200
+ * targets under OP_HOME as the current user. This prevents Docker from
201
+ * creating them as root-owned, which causes EACCES inside non-root
202
+ * containers.
256
203
  *
257
204
  * Only mount sources under `state.homeDir` are touched; external paths
258
205
  * (e.g. `/var/run/docker.sock`) are left alone.
206
+ *
207
+ * The file-vs-directory distinction is best-effort and only applies to
208
+ * explicit OP_HOME paths.
259
209
  */
260
210
  export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
261
- const composeFiles = discoverStackOverlays(state.stackDir);
211
+ const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
262
212
  if (composeFiles.length === 0) return;
263
213
 
264
214
  const envVars: Record<string, string> = {
265
215
  ...(process.env as Record<string, string>),
266
- ...parseEnvFile(`${state.stackDir}/stack.env`),
216
+ ...parseEnvFile(`${state.stashDir}/env/stack.env`),
267
217
  };
218
+ const homeRoot = resolvePath(state.homeDir);
268
219
 
269
220
  for (const file of composeFiles) {
270
221
  let doc: Record<string, unknown>;
@@ -291,18 +242,20 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
291
242
 
292
243
  const hostPath = expandEnvVars(rawSource, envVars);
293
244
  if (!hostPath || !hostPath.startsWith('/')) continue;
294
- if (existsSync(hostPath)) continue;
245
+ const resolvedHostPath = resolvePath(hostPath);
246
+ if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue;
247
+ if (existsSync(resolvedHostPath)) continue;
295
248
 
296
- // A basename containing a `.` (anywhere, including leading) is a file.
297
- // Bare names like `stack` or `data` are directories.
298
- const basename = hostPath.split('/').pop() ?? '';
249
+ // Only create mounts under OP_HOME. For now, treat existing explicit
250
+ // file paths as files and directory paths as directories.
251
+ const basename = resolvedHostPath.split('/').pop() ?? '';
299
252
  const isFile = basename.includes('.');
300
253
 
301
254
  if (isFile) {
302
- mkdirSync(dirname(hostPath), { recursive: true });
303
- writeFileSync(hostPath, '');
255
+ mkdirSync(dirname(resolvedHostPath), { recursive: true });
256
+ writeFileSync(resolvedHostPath, '');
304
257
  } else {
305
- mkdirSync(hostPath, { recursive: true });
258
+ mkdirSync(resolvedHostPath, { recursive: true });
306
259
  }
307
260
  }
308
261
  }
@@ -322,24 +275,25 @@ export function writeRuntimeFiles(
322
275
  writeFileSync(composePath, state.artifacts.compose);
323
276
  }
324
277
 
325
- // Load persisted channel HMAC secrets from guardian.env,
326
- // then generate new ones for new channel addons.
327
- const channelSecrets = readChannelSecrets(state.stackDir);
278
+ for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
279
+ const path = `${state.stackDir}/${name}`;
280
+ if (!existsSync(path)) writeFileSync(path, readBundledStackAsset(name));
281
+ }
282
+
328
283
  for (const addon of listEnabledAddonIds(state.homeDir)) {
329
- const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
330
- if (isChannelAddon(composePath) && !channelSecrets[addon]) {
331
- channelSecrets[addon] = randomHex(16);
284
+ if (['api', 'chat', 'discord', 'slack'].includes(addon)) {
285
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
286
+ ensureChannelSecret(state.stackDir, channel);
287
+ }
288
+ break;
332
289
  }
333
290
  }
334
291
 
335
- // Write channel secrets to guardian.env (the canonical source)
336
- writeChannelSecrets(state.stackDir, channelSecrets);
337
-
338
- // Write system.env (no channel secrets — those live in guardian.env)
292
+ // Write stack.env (no secrets those live in knowledge/secrets/)
339
293
  writeSystemEnv(state);
340
294
 
341
295
  // Ensure state directory exists
342
- mkdirSync(state.stateDir, { recursive: true });
296
+ mkdirSync(state.dataDir, { recursive: true });
343
297
 
344
298
  state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
345
299
  }
@@ -11,21 +11,21 @@ describe("seedStashAssets", () => {
11
11
  beforeEach(() => {
12
12
  homeDir = mkdtempSync(join(tmpdir(), "stash-seed-test-"));
13
13
  process.env.OP_HOME = homeDir;
14
- mkdirSync(join(homeDir, "stash"), { recursive: true });
14
+ mkdirSync(join(homeDir, "knowledge"), { recursive: true });
15
15
  });
16
16
 
17
17
  afterEach(() => {
18
18
  process.env.OP_HOME = originalHome;
19
19
  // Restore writable mode in case a test chmod'd the stash dir.
20
20
  try {
21
- chmodSync(join(homeDir, "stash"), 0o755);
21
+ chmodSync(join(homeDir, "knowledge"), 0o755);
22
22
  } catch {
23
23
  // ignore — dir may not exist
24
24
  }
25
25
  rmSync(homeDir, { recursive: true, force: true });
26
26
  });
27
27
 
28
- it("writes every seed under stash/ on first run", () => {
28
+ it("writes every seed under knowledge/ on first run", () => {
29
29
  const seeds = {
30
30
  "skills/test-skill/SKILL.md": "---\nname: test-skill\ntype: skill\n---\nhello\n",
31
31
  "commands/test-cmd.md": "---\nname: test-cmd\ntype: command\n---\nrun me\n",
@@ -34,7 +34,7 @@ describe("seedStashAssets", () => {
34
34
 
35
35
  expect(written.sort()).toEqual(Object.keys(seeds).sort());
36
36
  for (const [rel, content] of Object.entries(seeds)) {
37
- const target = join(homeDir, "stash", rel);
37
+ const target = join(homeDir, "knowledge", rel);
38
38
  expect(existsSync(target)).toBe(true);
39
39
  expect(readFileSync(target, "utf-8")).toBe(content);
40
40
  }
@@ -46,7 +46,7 @@ describe("seedStashAssets", () => {
46
46
 
47
47
  // Simulate a previous install: seed first.
48
48
  seedStashAssets(seeds);
49
- const target = join(homeDir, "stash/skills/keep-mine/SKILL.md");
49
+ const target = join(homeDir, "knowledge/skills/keep-mine/SKILL.md");
50
50
  expect(readFileSync(target, "utf-8")).toBe("ORIGINAL SEED\n");
51
51
 
52
52
  // User edits the file.
@@ -58,10 +58,10 @@ describe("seedStashAssets", () => {
58
58
  expect(readFileSync(target, "utf-8")).toBe(userEdit);
59
59
  });
60
60
 
61
- it("creates nested directories under stash/ as needed", () => {
61
+ it("creates nested directories under knowledge/ as needed", () => {
62
62
  const seeds = { "skills/deep/nested/asset/SKILL.md": "x" };
63
63
  seedStashAssets(seeds);
64
- expect(existsSync(join(homeDir, "stash/skills/deep/nested/asset/SKILL.md"))).toBe(true);
64
+ expect(existsSync(join(homeDir, "knowledge/skills/deep/nested/asset/SKILL.md"))).toBe(true);
65
65
  });
66
66
 
67
67
  it("returns an empty list when called with no seeds", () => {
@@ -70,7 +70,7 @@ describe("seedStashAssets", () => {
70
70
 
71
71
  it("rejects seed keys that escape the stash directory", () => {
72
72
  // Path-traversal guard: ../ sequences in keys must throw rather than
73
- // silently writing outside stash/.
73
+ // silently writing outside knowledge/.
74
74
  expect(() =>
75
75
  seedStashAssets({ "../../etc/cron.d/evil": "owned\n" }),
76
76
  ).toThrow(/escapes stash dir/);
@@ -91,7 +91,7 @@ describe("seedStashAssets", () => {
91
91
  const uid = process.getuid?.();
92
92
  if (uid === 0) return;
93
93
 
94
- const stashDir = join(homeDir, "stash");
94
+ const stashDir = join(homeDir, "knowledge");
95
95
  chmodSync(stashDir, 0o555);
96
96
  try {
97
97
  expect(() =>