@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,423 @@
1
+ /**
2
+ * On-disk layout migration harness.
3
+ *
4
+ * `stack.env` carries `OP_LAYOUT_VERSION` — the authoritative marker of the
5
+ * home-directory layout schema. `ensureMigrated()` runs BEFORE any state
6
+ * validation (createState/resolveRuntimeFiles assume the current layout), so it
7
+ * must resolve its own paths rather than take a built ControlPlaneState.
8
+ *
9
+ * Contract (the fail-safe invariant):
10
+ * - Fast path: if the layout is already current, return immediately — no lock,
11
+ * no backup, zero overhead on routine updates.
12
+ * - Otherwise: acquire the install lock, take a FULL-HOME backup first, and
13
+ * abort the whole upgrade if the backup fails (never migrate without a
14
+ * verified safety copy).
15
+ * - Migrations are COPY-ONLY / additive (never delete the old layout), so a
16
+ * mid-run failure leaves the home fully recoverable.
17
+ * - The OP_LAYOUT_VERSION bump is the LAST step (the commit point); a crash
18
+ * before it just re-runs next time (idempotent).
19
+ * - On failure, throw MigrationError carrying the backup path + recovery
20
+ * guidance for the CLI/UI to surface.
21
+ */
22
+ import {
23
+ existsSync, mkdirSync, readFileSync, writeFileSync,
24
+ readdirSync, statSync, chmodSync, cpSync,
25
+ } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { parse as yamlParse } from "yaml";
28
+ import {
29
+ resolveOpenPalmHome, resolveDataDir, resolveStackDir, resolveStashDir, resolveConfigDir,
30
+ } from "./home.js";
31
+ import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
32
+ import { backupOpenPalmHome } from "./backup.js";
33
+ import { upsertEnvValue } from "./env.js";
34
+
35
+ export const LAYOUT_VERSION_KEY = "OP_LAYOUT_VERSION";
36
+ /** Bump when the on-disk layout changes and add a Migration to MIGRATIONS. */
37
+ export const CURRENT_LAYOUT_VERSION = 1;
38
+
39
+ const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
40
+
41
+ export interface MigrationReport {
42
+ migrated: boolean;
43
+ from: number;
44
+ to: number;
45
+ applied: string[];
46
+ backupDir: string | null;
47
+ notes: string[];
48
+ }
49
+
50
+ export class MigrationError extends Error {
51
+ constructor(
52
+ message: string,
53
+ readonly guidance: string,
54
+ readonly backupDir: string | null,
55
+ ) {
56
+ super(message);
57
+ this.name = "MigrationError";
58
+ }
59
+ }
60
+
61
+ interface MigrationCtx {
62
+ homeDir: string;
63
+ dataDir: string;
64
+ stackDir: string;
65
+ stashDir: string;
66
+ configDir: string;
67
+ dryRun: boolean;
68
+ log: (m: string) => void;
69
+ notes: string[];
70
+ }
71
+
72
+ interface Migration {
73
+ from: number;
74
+ to: number;
75
+ describe: string;
76
+ apply(ctx: MigrationCtx): void;
77
+ verify(ctx: MigrationCtx): void;
78
+ }
79
+
80
+ // ── Layout-version read/write (stack.env is the single source of truth) ───────
81
+
82
+ function stackEnvFile(stashDir: string): string {
83
+ return join(stashDir, "env", "stack.env");
84
+ }
85
+
86
+ /**
87
+ * Resolve the current on-disk layout version.
88
+ * - explicit OP_LAYOUT_VERSION in knowledge/env/stack.env wins
89
+ * - else a top-level `vault/` directory ⇒ 0.10.x layout (version 0)
90
+ * - else assume the current layout (a pre-marker 0.11 install) — caller stamps it
91
+ */
92
+ function readLayoutVersion(ctx: { homeDir: string; stashDir: string }): number {
93
+ const envPath = stackEnvFile(ctx.stashDir);
94
+ if (existsSync(envPath)) {
95
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
96
+ const m = line.match(/^OP_LAYOUT_VERSION=(\d+)\s*$/);
97
+ if (m) return Number(m[1]);
98
+ }
99
+ }
100
+ if (existsSync(join(ctx.homeDir, "vault"))) return 0;
101
+ return CURRENT_LAYOUT_VERSION;
102
+ }
103
+
104
+ function stampLayoutVersion(stashDir: string, version: number): void {
105
+ const envPath = stackEnvFile(stashDir);
106
+ if (!existsSync(envPath)) return; // nothing to stamp; not a usable install
107
+ const next = upsertEnvValue(readFileSync(envPath, "utf-8"), LAYOUT_VERSION_KEY, String(version));
108
+ writeFileSync(envPath, next);
109
+ }
110
+
111
+ // ── helpers (non-destructive: copy, never delete the source) ──────────────────
112
+
113
+ function ensureDir(ctx: MigrationCtx, dir: string): void {
114
+ if (ctx.dryRun) { ctx.log(`[dry-run] mkdir ${rel(ctx, dir)}`); return; }
115
+ mkdirSync(dir, { recursive: true });
116
+ try { chmodSync(dir, 0o700); } catch { /* Windows / best-effort */ }
117
+ }
118
+
119
+ function rel(ctx: MigrationCtx, p: string): string {
120
+ return p.startsWith(ctx.homeDir) ? p.slice(ctx.homeDir.length + 1) : p;
121
+ }
122
+
123
+ /** Copy src→dest; skip if dest exists; chmod 600. Returns true if copied. */
124
+ function copyIfAbsent(ctx: MigrationCtx, src: string, dest: string): boolean {
125
+ if (!existsSync(src)) return false;
126
+ if (existsSync(dest)) { ctx.log(`skip (exists): ${rel(ctx, dest)}`); return false; }
127
+ if (ctx.dryRun) { ctx.log(`[dry-run] copy ${rel(ctx, src)} -> ${rel(ctx, dest)}`); return true; }
128
+ cpSync(src, dest, { recursive: true });
129
+ try { if (statSync(dest).isFile()) chmodSync(dest, 0o600); } catch { /* best-effort */ }
130
+ ctx.log(`copied: ${rel(ctx, src)} -> ${rel(ctx, dest)}`);
131
+ return true;
132
+ }
133
+
134
+ function writeFile600(ctx: MigrationCtx, path: string, content: string): void {
135
+ if (ctx.dryRun) { ctx.log(`[dry-run] write ${rel(ctx, path)}`); return; }
136
+ writeFileSync(path, content);
137
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
138
+ }
139
+
140
+ // ── Migration 0 → 1: 0.10.x `vault/` layout → 0.11.0 knowledge/ layout ────────
141
+
142
+ const SECRET_KEY_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD)$/;
143
+ const CONFIG_KEY_RE = /^(OP_CAP_|SYSTEM_LLM_|EMBEDDING_)/;
144
+
145
+ function migrate010to011(ctx: MigrationCtx): void {
146
+ const vault = join(ctx.homeDir, "vault");
147
+ const newEnv = join(ctx.stashDir, "env");
148
+ const newSecrets = join(ctx.stashDir, "secrets");
149
+ ensureDir(ctx, newEnv);
150
+ ensureDir(ctx, newSecrets);
151
+
152
+ // user.env → knowledge/env/user.env
153
+ copyIfAbsent(ctx, join(vault, "user", "user.env"), join(newEnv, "user.env"));
154
+
155
+ // stack.env transform → knowledge/env/stack.env
156
+ const srcStack = join(vault, "stack", "stack.env");
157
+ const destStack = join(newEnv, "stack.env");
158
+ if (existsSync(srcStack) && !existsSync(destStack)) {
159
+ const kept: string[] = [];
160
+ const removed: string[] = [];
161
+ for (const line of readFileSync(srcStack, "utf-8").split("\n")) {
162
+ if (line === "" || line.startsWith("#")) { kept.push(line); continue; }
163
+ const eq = line.indexOf("=");
164
+ const key = eq >= 0 ? line.slice(0, eq) : line;
165
+ const val = eq >= 0 ? line.slice(eq + 1) : "";
166
+ if (key === "OP_UI_LOGIN_PASSWORD") {
167
+ writeFile600(ctx, join(newSecrets, "op_ui_login_password"), val + "\n");
168
+ ctx.log("extracted OP_UI_LOGIN_PASSWORD -> knowledge/secrets/op_ui_login_password");
169
+ } else if (key === "OP_ADMIN_PORT") {
170
+ kept.push(`OP_HOST_UI_PORT=${val}`);
171
+ ctx.log("renamed OP_ADMIN_PORT -> OP_HOST_UI_PORT");
172
+ } else if (key === "OP_ADMIN_OPENCODE_PORT" || key === "OP_GUARDIAN_PORT") {
173
+ ctx.log(`dropped removed var: ${key}`);
174
+ } else if (key.startsWith("TTS_") || key.startsWith("STT_")) {
175
+ kept.push(`OP_${key}=${val}`);
176
+ ctx.log(`renamed ${key} -> OP_${key}`);
177
+ } else if (CONFIG_KEY_RE.test(key) || SECRET_KEY_RE.test(key)) {
178
+ removed.push(line);
179
+ ctx.log(`quarantined: ${key}`);
180
+ } else {
181
+ kept.push(line);
182
+ }
183
+ }
184
+ writeFile600(ctx, destStack, kept.join("\n") + "\n");
185
+ if (removed.length > 0) {
186
+ writeFile600(ctx, join(newEnv, "stack.env.removed-secrets.bak"), removed.join("\n") + "\n");
187
+ ctx.notes.push(
188
+ "Secret/capability keys were removed from stack.env (saved to knowledge/env/stack.env.removed-secrets.bak) — re-enter provider keys via the Connections tab and LLM config via config/akm/config.json; do not put them back in stack.env.",
189
+ );
190
+ }
191
+ }
192
+
193
+ // provider creds (best-effort) + service secrets
194
+ if (copyIfAbsent(ctx, join(vault, "stack", "auth.json"), join(newSecrets, "auth.json"))) {
195
+ ctx.notes.push("Copied auth.json best-effort — verify providers in the Connections tab and re-add any that are missing (the OpenCode auth format changed).");
196
+ }
197
+ const servicesDir = join(vault, "stack", "services");
198
+ if (existsSync(servicesDir)) {
199
+ for (const name of readdirSync(servicesDir)) {
200
+ copyIfAbsent(ctx, join(servicesDir, name), join(newSecrets, name));
201
+ }
202
+ }
203
+
204
+ // channel HMAC secrets: split CHANNEL_<NAME>_SECRET into per-secret files
205
+ const guardianEnv = join(vault, "stack", "guardian.env");
206
+ if (existsSync(guardianEnv)) {
207
+ for (const line of readFileSync(guardianEnv, "utf-8").split("\n")) {
208
+ const m = line.match(/^CHANNEL_(.+)_SECRET=(.*)$/);
209
+ if (!m) continue;
210
+ const name = m[1].toLowerCase();
211
+ if (!ADDON_NAME_RE.test(name)) continue;
212
+ const dest = join(newSecrets, `channel_${name}_secret`);
213
+ if (existsSync(dest)) { ctx.log(`skip (exists): knowledge/secrets/channel_${name}_secret`); continue; }
214
+ writeFile600(ctx, dest, m[2] + "\n");
215
+ ctx.log(`channel secret: ${m[1]} -> knowledge/secrets/channel_${name}_secret`);
216
+ }
217
+ }
218
+
219
+ // user credential files mounted into the assistant at /etc/openpalm
220
+ for (const n of ["apprise.yaml", "apprise.conf", "gcloud-credentials.json"]) {
221
+ copyIfAbsent(ctx, join(vault, "user", n), join(newSecrets, n));
222
+ }
223
+ for (const d of [".gws", ".gcloud", ".mgc"]) {
224
+ copyIfAbsent(ctx, join(vault, "user", d), join(newSecrets, d));
225
+ }
226
+
227
+ // Leave a README in the retained legacy vault/ explaining that it is now a
228
+ // recovery copy and how to remove it safely once 0.11.x is confirmed working.
229
+ writeVaultReadme(ctx, vault);
230
+
231
+ // Addon enablement: stack.yml is removed in 0.11.0. Convert any addons[] from
232
+ // a legacy stack.yml (config/stack.yml or config/stack/stack.yml) into
233
+ // OP_ENABLED_ADDONS in stack.env. Do NOT create stack.yml.
234
+ const addons = readLegacyStackYmlAddons(ctx);
235
+ if (addons.length > 0) {
236
+ const envPath = stackEnvFile(ctx.stashDir);
237
+ if (ctx.dryRun) {
238
+ ctx.log(`[dry-run] set OP_ENABLED_ADDONS=${addons.join(",")}`);
239
+ } else if (existsSync(envPath)) {
240
+ writeFile600(ctx, envPath, upsertEnvValue(readFileSync(envPath, "utf-8"), "OP_ENABLED_ADDONS", addons.join(",")));
241
+ ctx.log(`set OP_ENABLED_ADDONS=${addons.join(",")}`);
242
+ }
243
+ }
244
+ }
245
+
246
+ const VAULT_README = `# This \`vault/\` directory is from OpenPalm 0.10.x — it is now a RECOVERY COPY
247
+
248
+ OpenPalm 0.11.0 changed the on-disk layout. The upgrade **copied** everything out
249
+ of this \`vault/\` directory into the new locations and left these originals here,
250
+ untouched, as a safety net:
251
+
252
+ - \`vault/user/user.env\` → \`knowledge/env/user.env\`
253
+ - \`vault/stack/stack.env\` → \`knowledge/env/stack.env\` (transformed)
254
+ - \`vault/stack/guardian.env\` → \`knowledge/secrets/channel_<name>_secret\`
255
+ - \`vault/stack/services/*\` → \`knowledge/secrets/\`
256
+ - \`vault/stack/auth.json\` → \`knowledge/secrets/auth.json\` (best-effort)
257
+ - other user credential files → \`knowledge/secrets/\`
258
+
259
+ A full backup of your home was also taken under \`data/backups/\` before migrating.
260
+
261
+ Nothing in 0.11.x reads this directory anymore. You can delete it once you have
262
+ confirmed the new version works.
263
+
264
+ ## How to remove it safely
265
+
266
+ 1. Confirm 0.11.x is healthy: the stack starts (\`openpalm status\`), you can sign
267
+ in to the UI, your providers are connected (Connections tab), and your
268
+ channels still work.
269
+ 2. Spot-check that your data is in the new layout:
270
+ - \`knowledge/env/stack.env\` and \`knowledge/env/user.env\` look right
271
+ - your secrets are under \`knowledge/secrets/\` (login password, channel
272
+ secrets, \`auth.json\`, etc.)
273
+ - if \`knowledge/env/stack.env.removed-secrets.bak\` exists, re-enter those
274
+ provider keys (Connections) and LLM config (\`config/akm/config.json\`) — do
275
+ not put secrets back into \`stack.env\`.
276
+ 3. Only then remove this directory. Prefer your OS trash (reversible):
277
+ - Linux: \`gio trash ~/.openpalm/vault\` (or \`trash-put ~/.openpalm/vault\`)
278
+ - macOS: \`trash ~/.openpalm/vault\`
279
+ - Windows: delete \`%USERPROFILE%\\.openpalm\\vault\` (sends to Recycle Bin)
280
+ - Last resort (irreversible): \`rm -rf ~/.openpalm/vault\`
281
+
282
+ If anything looks wrong, do NOT delete this directory — restore from it or from
283
+ \`data/backups/\`. Full guide: docs/operations/upgrade-0.10-to-0.11.md
284
+ `;
285
+
286
+ /** Drop a safe-removal README into the retained legacy vault/ (skip if present). */
287
+ function writeVaultReadme(ctx: MigrationCtx, vault: string): void {
288
+ const dest = join(vault, "README.md");
289
+ if (existsSync(dest)) { ctx.log("skip (exists): vault/README.md"); return; }
290
+ if (ctx.dryRun) { ctx.log("[dry-run] write vault/README.md (safe-removal guide)"); return; }
291
+ writeFileSync(dest, VAULT_README);
292
+ ctx.log("wrote vault/README.md (safe-removal guide)");
293
+ }
294
+
295
+ /** Extract a validated addons[] list from any legacy stack.yml, or []. */
296
+ function readLegacyStackYmlAddons(ctx: MigrationCtx): string[] {
297
+ for (const p of [join(ctx.configDir, "stack.yml"), join(ctx.stackDir, "stack.yml")]) {
298
+ if (!existsSync(p)) continue;
299
+ try {
300
+ const raw = yamlParse(readFileSync(p, "utf-8")) as { addons?: unknown };
301
+ if (Array.isArray(raw?.addons)) {
302
+ return [...new Set(raw.addons.filter((v): v is string => typeof v === "string" && ADDON_NAME_RE.test(v)))].sort();
303
+ }
304
+ } catch { /* ignore unparseable */ }
305
+ }
306
+ return [];
307
+ }
308
+
309
+ const MIGRATIONS: Migration[] = [
310
+ {
311
+ from: 0,
312
+ to: 1,
313
+ describe: "0.10.x vault/ layout → 0.11.0 knowledge/ layout",
314
+ apply: migrate010to011,
315
+ verify(ctx) {
316
+ // The migration must have produced a usable 0.11 stack.env (unless a
317
+ // dry-run, where nothing was written).
318
+ if (ctx.dryRun) return;
319
+ if (!existsSync(stackEnvFile(ctx.stashDir))) {
320
+ throw new Error("post-migration check failed: knowledge/env/stack.env is missing");
321
+ }
322
+ },
323
+ },
324
+ ];
325
+
326
+ // ── Public entry point ────────────────────────────────────────────────────────
327
+
328
+ const RECOVERY_GUIDANCE =
329
+ "Your original files were left untouched and a full backup was taken first. " +
330
+ "To recover, restore the backup (see docs/operations/backup-restore.md) or run " +
331
+ "the standalone migrator with --dry-run (scripts/migrate-0.10-to-0.11.sh / .ps1). " +
332
+ "Full guide: docs/operations/upgrade-0.10-to-0.11.md";
333
+
334
+ /**
335
+ * Ensure the home directory is migrated to the current layout. Safe to call at
336
+ * the top of any upgrade/install entry point. Resolves its own paths (must run
337
+ * before createState, which assumes the current layout).
338
+ */
339
+ export function ensureMigrated(opts: { homeDir?: string; dryRun?: boolean; log?: (m: string) => void } = {}): MigrationReport {
340
+ const homeDir = opts.homeDir ?? resolveOpenPalmHome();
341
+ const dryRun = opts.dryRun ?? false;
342
+ const log = opts.log ?? (() => {});
343
+ const stashDir = resolveStashDir();
344
+ const ctxBase = {
345
+ homeDir,
346
+ dataDir: resolveDataDir(),
347
+ stackDir: resolveStackDir(),
348
+ stashDir,
349
+ configDir: resolveConfigDir(),
350
+ dryRun,
351
+ log,
352
+ notes: [] as string[],
353
+ };
354
+
355
+ const from = readLayoutVersion(ctxBase);
356
+ const empty: MigrationReport = { migrated: false, from, to: from, applied: [], backupDir: null, notes: [] };
357
+
358
+ // Fast path: already current → just ensure the marker is stamped, no lock/backup.
359
+ if (from >= CURRENT_LAYOUT_VERSION) {
360
+ if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
361
+ return { ...empty, to: CURRENT_LAYOUT_VERSION };
362
+ }
363
+
364
+ const pending = MIGRATIONS
365
+ .filter((m) => m.from >= from && m.to <= CURRENT_LAYOUT_VERSION)
366
+ .sort((a, b) => a.from - b.from);
367
+ if (pending.length === 0) {
368
+ if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
369
+ return { ...empty, to: CURRENT_LAYOUT_VERSION };
370
+ }
371
+
372
+ let lock: ReturnType<typeof acquireInstallLock> = null;
373
+ let backupDir: string | null = null;
374
+ try {
375
+ // Mutual exclusion + backup gate: never migrate without a verified safety
376
+ // copy. Any failure here aborts with no changes made.
377
+ if (!dryRun) {
378
+ try {
379
+ mkdirSync(ctxBase.dataDir, { recursive: true });
380
+ } catch (e) {
381
+ throw new MigrationError(`Could not prepare the data directory: ${e instanceof Error ? e.message : String(e)}`, RECOVERY_GUIDANCE, null);
382
+ }
383
+ lock = acquireInstallLock(ctxBase.dataDir);
384
+ if (!lock) {
385
+ throw new MigrationError("Another install/upgrade is in progress.", RECOVERY_GUIDANCE, null);
386
+ }
387
+ log("Taking a full backup before migrating…");
388
+ try {
389
+ backupDir = backupOpenPalmHome(homeDir);
390
+ } catch (e) {
391
+ throw new MigrationError(`Could not create a safety backup; upgrade aborted (no changes made): ${e instanceof Error ? e.message : String(e)}`, RECOVERY_GUIDANCE, null);
392
+ }
393
+ if (!backupDir) {
394
+ throw new MigrationError("Could not create a safety backup; upgrade aborted (no changes made).", RECOVERY_GUIDANCE, null);
395
+ }
396
+ log(`Backup: ${backupDir}`);
397
+ }
398
+
399
+ const applied: string[] = [];
400
+ const notes: string[] = [];
401
+ for (const m of pending) {
402
+ const ctx: MigrationCtx = { ...ctxBase, notes };
403
+ log(`Migrating layout ${m.from} → ${m.to}: ${m.describe}`);
404
+ m.apply(ctx);
405
+ m.verify(ctx);
406
+ applied.push(`${m.from}->${m.to}`);
407
+ }
408
+
409
+ // Commit point: bump the layout version LAST.
410
+ if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
411
+
412
+ return { migrated: true, from, to: CURRENT_LAYOUT_VERSION, applied, backupDir, notes };
413
+ } catch (e) {
414
+ if (e instanceof MigrationError) throw e;
415
+ throw new MigrationError(
416
+ `Migration failed: ${e instanceof Error ? e.message : String(e)}`,
417
+ RECOVERY_GUIDANCE,
418
+ backupDir,
419
+ );
420
+ } finally {
421
+ releaseInstallLock(lock);
422
+ }
423
+ }
@@ -2,7 +2,7 @@
2
2
  * Shared OpenCode REST API client.
3
3
  *
4
4
  * Factory function that returns typed accessors for an OpenCode server
5
- * at a configurable base URL. Used by both the admin (container) and
5
+ * at a configurable base URL. Used by both the admin UI (host process) and
6
6
  * CLI (host subprocess) to talk to OpenCode.
7
7
  */
8
8
 
@@ -5,78 +5,93 @@
5
5
  * When the directory layout changes, update this file only.
6
6
  *
7
7
  * Layout:
8
- * config/ — user-editable config + system config files (auth.json, akm/)
9
- * config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
10
- * cache/ regenerable/semi-persistent data (akm cache, guardian cache, rollback)
11
- * state/ persistent service data (assistant, admin, guardian, logs, backups, registry)
12
- * stash/ — akm knowledge (skills, vaults, agents)
8
+ * config/ — user-editable config + system config files (akm/)
9
+ * config/stack/ — fixed compose files only (stack.env, secrets, auth.json live under knowledge/; no stack.yml)
10
+ * data/ — persistent service data, logs, backups, rollback
11
+ * knowledge/ akm knowledge (skills, env, secrets, agents)
13
12
  * workspace/ — shared work area
14
13
  */
14
+ import { dirname, basename } from "node:path";
15
15
  import type { ControlPlaneState } from "./types.js";
16
16
 
17
17
  // ── Config directory — user + system config ─────────────────────────────────
18
18
 
19
- /** OpenCode auth token store */
20
- export const authJsonPath = (s: ControlPlaneState): string => `${s.configDir}/auth.json`;
21
- /** akm setup config directory (AKM_CONFIG_DIR) */
19
+ /**
20
+ * OpenCode auth token store. Provider credentials are sensitive, so they live
21
+ * under knowledge/secrets/ (out of config/stack/) and are bind-mounted into
22
+ * every OpenCode-based container (assistant + guardian).
23
+ */
24
+ export const authJsonPath = (s: ControlPlaneState): string => `${s.stashDir}/secrets/auth.json`;
25
+ /** akm config directory mounted at /etc/akm */
22
26
  export const akmConfigDir = (s: ControlPlaneState): string => `${s.configDir}/akm`;
23
- /** akm setup config file (written by admin on capability save) */
27
+ /** akm setup config file (written by the admin UI AKM action and CLI install) */
24
28
  export const akmConfigPath = (s: ControlPlaneState): string => `${s.configDir}/akm/config.json`;
25
29
  export const tasksDir = (s: ControlPlaneState): string => `${s.stashDir}/tasks`;
26
30
  export const assistantConfigDir = (s: ControlPlaneState): string => `${s.configDir}/assistant`;
31
+ /** Guardian OpenCode global config dir — bind-mounted at /etc/opencode */
32
+ export const guardianConfigDir = (s: ControlPlaneState): string => `${s.configDir}/guardian`;
27
33
 
28
34
  // ── Config/stack directory — compose runtime + stack config ─────────────────
29
35
 
30
- /** System env: capabilities, secrets, tokens */
31
- export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
32
- /** Guardian HMAC channel secrets */
33
- export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
34
- /** Stack spec: capability assignments */
35
- export const stackSpecFilePath = (s: ControlPlaneState): string => `${s.stackDir}/stack.yml`;
36
-
37
- // ── Cache directory regenerable/semi-persistent ───────────────────────────
36
+ /**
37
+ * System env: non-secret runtime configuration (the Compose `--env-file`).
38
+ * Lives under knowledge/env/ alongside the user env file (akm `env:stack`).
39
+ */
40
+ export const stackEnvPath = (s: ControlPlaneState): string => `${s.stashDir}/env/stack.env`;
41
+ /**
42
+ * Resolve the OP_HOME root from a stackDir. Normally `<home>/config/stack`;
43
+ * falls back to the stackDir itself for callers/tests that pass a home-shaped
44
+ * dir. Mirrors `resolveHomeDirFromStackDir` in secrets-files.ts so the env and
45
+ * secret dirs resolve consistently from the same input.
46
+ */
47
+ const homeFromStackDir = (stackDir: string): string =>
48
+ basename(stackDir) === "stack" && basename(dirname(stackDir)) === "config"
49
+ ? dirname(dirname(stackDir))
50
+ : stackDir;
38
51
 
39
- export const akmCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm`;
40
- export const guardianCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/guardian`;
41
- export const rollbackDir = (s: ControlPlaneState): string => `${s.cacheDir}/rollback`;
52
+ /**
53
+ * Same as `stackEnvPath` but resolved from a `stackDir` for the few callers
54
+ * that only have the stack dir, not full state.
55
+ */
56
+ export const stackEnvPathFromStackDir = (stackDir: string): string => `${homeFromStackDir(stackDir)}/knowledge/env/stack.env`;
42
57
 
43
- // ── State directory persistent service data ───────────────────────────────
58
+ // ── Operational state directories ───────────────────────────────────────────
44
59
 
45
- export const assistantServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/assistant`;
46
- export const adminServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/admin`;
47
- export const guardianServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian`;
48
- export const guardianStashDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/stash`;
49
- export const guardianAkmDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/akm`;
50
- /** Shared akm operational data (data/, state/ — NOT config, which lives in config/akm/) */
51
- export const akmStateDir = (s: ControlPlaneState): string => `${s.stateDir}/akm`;
52
- export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
53
- export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
54
- export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
60
+ export const akmCacheDir = (s: ControlPlaneState): string => `${s.dataDir}/akm/cache`;
61
+ export const rollbackDir = (s: ControlPlaneState): string => `${s.dataDir}/rollback`;
62
+ export const logsDir = (s: ControlPlaneState): string => `${s.dataDir}/logs`;
55
63
  /**
56
64
  * Guardian's own audit log of channel ingress (HMAC verify, replay, rate
57
65
  * limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
58
66
  * `admin-audit.jsonl` — OpenCode session logs are the audit trail for
59
67
  * chat + tool activity.
60
68
  */
61
- export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
62
- /** One-shot 0.11.0 migration log (OP_UI_TOKEN OPENCODE_SERVER_PASSWORD, endpoints.json move) */
63
- export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
64
- export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
65
- export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
66
- export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
67
- export const registryAutomationsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/automations`;
68
- export const secretsDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets`;
69
- export const secretProviderPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/provider.json`;
70
- export const secretsIndexPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/plaintext-index.json`;
71
- export const passStoreDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets/pass-store`;
69
+ export const guardianAuditPath = (s: ControlPlaneState): string => `${s.dataDir}/logs/guardian-audit.log`;
70
+ export const backupsDir = (s: ControlPlaneState): string => `${s.dataDir}/backups`;
71
+
72
+ // ── State directory persistent service data ───────────────────────────────
72
73
 
73
- // ── Stash directory ─────────────────────────────────────────────────────────
74
+ export const assistantServiceDir = (s: ControlPlaneState): string => `${s.dataDir}/assistant`;
75
+ export const guardianServiceDir = (s: ControlPlaneState): string => `${s.dataDir}/guardian`;
76
+ export const guardianAkmDir = (s: ControlPlaneState): string => `${s.dataDir}/guardian/akm`;
77
+ /** akm durable data — NOT config, which lives in config/akm/ */
78
+ export const akmDataDir = (s: ControlPlaneState): string => `${s.dataDir}/akm/data`;
79
+ export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.dataDir}/akm/cache/tasks/logs/${id}`;
80
+ export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.dataDir}/akm/cache/tasks/logs`;
81
+ export const secretsDir = (s: ControlPlaneState): string => `${s.dataDir}/secrets`;
82
+ export const secretProviderPath = (s: ControlPlaneState): string => `${s.dataDir}/secrets/provider.json`;
83
+ export const secretsIndexPath = (s: ControlPlaneState): string => `${s.dataDir}/secrets/plaintext-index.json`;
84
+ export const passStoreDir = (s: ControlPlaneState): string => `${s.dataDir}/secrets/pass-store`;
74
85
 
75
- /** akm vault:user file — lives in the stash */
76
- export const akmUserVaultPath = (s: ControlPlaneState): string => `${s.stashDir}/vaults/user.env`;
86
+ // ── Knowledge directory ─────────────────────────────────────────────────────
87
+ // The akm env:user file path (`knowledge/env/user.env`) is owned by
88
+ // `akm-user-env.ts` (`userEnvPathSync`), which also handles its read/write and
89
+ // legacy migration — kept there rather than duplicated as a bare path here.
77
90
 
78
91
  // ── Stack directory ─────────────────────────────────────────────────────────
79
92
 
80
93
  export const coreComposePath = (s: ControlPlaneState): string => `${s.stackDir}/core.compose.yml`;
81
- export const addonsStackDir = (s: ControlPlaneState): string => `${s.stackDir}/addons`;
94
+ export const servicesComposePath = (s: ControlPlaneState): string => `${s.stackDir}/services.compose.yml`;
95
+ export const channelsComposePath = (s: ControlPlaneState): string => `${s.stackDir}/channels.compose.yml`;
96
+ export const customComposePath = (s: ControlPlaneState): string => `${s.stackDir}/custom.compose.yml`;
82
97
  export const addonComposePath = (s: ControlPlaneState, name: string): string => `${s.stackDir}/addons/${name}/compose.yml`;
@@ -0,0 +1,21 @@
1
+ export type HardwareProfileVariant = 'cpu' | 'cuda' | 'rocm';
2
+
3
+ const PROFILE_ID_RE = /^addon\.([a-z0-9-]+)(?:\.(cpu|cuda|rocm))?$/;
4
+
5
+ export function addonProfileId(addon: string, variant: HardwareProfileVariant): string {
6
+ return `addon.${addon}.${variant}`;
7
+ }
8
+
9
+ export function resolveHardwareProfileVariant(profileId: string): HardwareProfileVariant | null {
10
+ return (profileId.match(PROFILE_ID_RE)?.[2] as HardwareProfileVariant | undefined) ?? null;
11
+ }
12
+
13
+ export function canonicalAddonProfileSelection(addon: string, profile: string): string {
14
+ const trimmed = profile.trim();
15
+ if (!trimmed) return '';
16
+
17
+ const match = trimmed.match(PROFILE_ID_RE);
18
+ if (!match || match[1] !== addon) return '';
19
+
20
+ return trimmed;
21
+ }
@@ -4,7 +4,7 @@
4
4
  * Used by the admin capabilities test endpoint and the CLI setup wizard
5
5
  * to enumerate the models a configured provider exposes.
6
6
  */
7
- import { readStackEnv } from "./secrets.js";
7
+ import { readStackRuntimeEnv } from "./secrets.js";
8
8
  import { PROVIDER_DEFAULT_URLS } from "../provider-constants.js";
9
9
 
10
10
  /** Static model list for Anthropic (no listing API available). */
@@ -24,7 +24,7 @@ const ANTHROPIC_MODELS = [
24
24
  *
25
25
  * - Empty input → empty string.
26
26
  * - `env:NAME` form → looks up `NAME` in `process.env` first, then falls back
27
- * to `config/stack/stack.env` resolved against `stackDir`.
27
+ * to `knowledge/secrets/<NAME>` resolved against `stackDir`.
28
28
  * - Anything else → returned verbatim (treated as a literal key value).
29
29
  */
30
30
  function resolveApiKey(apiKeyRef: string, stackDir: string): string {
@@ -34,7 +34,7 @@ function resolveApiKey(apiKeyRef: string, stackDir: string): string {
34
34
  const varName = apiKeyRef.slice(4);
35
35
  if (process.env[varName]) return process.env[varName]!;
36
36
 
37
- const secrets = readStackEnv(stackDir);
37
+ const secrets = readStackRuntimeEnv(stackDir);
38
38
  return secrets[varName] ?? "";
39
39
  }
40
40