@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +69 -30
- package/src/control-plane/compose-args.ts +62 -8
- package/src/control-plane/config-persistence.ts +102 -136
- package/src/control-plane/core-assets.ts +45 -60
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +16 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +100 -136
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +45 -40
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +301 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +137 -61
- package/src/control-plane/setup.ts +82 -63
- package/src/control-plane/skeleton-guardrail.test.ts +66 -56
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +51 -14
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +333 -0
- package/src/control-plane/ui-assets.ts +290 -142
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +96 -26
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
- package/src/control-plane/stack-spec.test.ts +0 -94
- 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 (
|
|
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 (
|
|
9
|
-
* config/stack/ — compose
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
// ──
|
|
58
|
+
// ── Operational state directories ───────────────────────────────────────────
|
|
44
59
|
|
|
45
|
-
export const
|
|
46
|
-
export const
|
|
47
|
-
export const
|
|
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.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
37
|
+
const secrets = readStackRuntimeEnv(stackDir);
|
|
38
38
|
return secrets[varName] ?? "";
|
|
39
39
|
}
|
|
40
40
|
|