@openpalm/lib 0.11.0-rc.6 → 0.11.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-rc.6",
3
+ "version": "0.11.1",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -47,7 +47,9 @@ function seedAddon(name: string): void {
47
47
  const stackDir = join(tempDir, "config", "stack");
48
48
  mkdirSync(stackDir, { recursive: true });
49
49
  writeFileSync(join(stackDir, "channels.compose.yml"), `services:\n ${name}:\n profiles: [\"addon.${name}\"]\n image: test\n`);
50
- writeFileSync(join(stackDir, "stack.yml"), `version: 2\naddons:\n - ${name}\n`);
50
+ const envDir = join(tempDir, "knowledge", "env");
51
+ mkdirSync(envDir, { recursive: true });
52
+ writeFileSync(join(envDir, "stack.env"), `OP_ENABLED_ADDONS=${name}\n`);
51
53
  }
52
54
 
53
55
  beforeEach(() => {
@@ -70,7 +72,7 @@ describe("buildComposeOptions", () => {
70
72
  expect(opts.files[0]).toContain("core.compose.yml");
71
73
  });
72
74
 
73
- it("includes fixed channel compose and profile from stack.yml", () => {
75
+ it("includes fixed channel compose and profile from OP_ENABLED_ADDONS", () => {
74
76
  seedCoreCompose();
75
77
  seedAddon("chat");
76
78
 
@@ -10,9 +10,8 @@ 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";
13
+ import { parseEnvFile, parseEnabledAddons } from "./env.js";
14
14
  import { canonicalAddonProfileSelection } from "./profile-ids.js";
15
- import { listStackSpecAddons } from "./stack-spec.js";
16
15
 
17
16
  // ── Types ────────────────────────────────────────────────────────────────
18
17
 
@@ -45,7 +44,7 @@ export function resolveActiveProfiles(state: ControlPlaneState): string[] {
45
44
  if (ollamaProfile) profiles.push(ollamaProfile);
46
45
  }
47
46
 
48
- for (const addon of listStackSpecAddons(state.stackDir)) {
47
+ for (const addon of parseEnabledAddons(env.OP_ENABLED_ADDONS)) {
49
48
  if (addon === 'voice') {
50
49
  profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
51
50
  } else if (addon === 'ollama') {
@@ -5,16 +5,18 @@
5
5
  * Files are validated in-place before writing; rollback is handled by
6
6
  * the rollback module (snapshot to OP_HOME/data/rollback/).
7
7
  */
8
- import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
8
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, chownSync } from "node:fs";
9
9
  import { dirname, resolve as resolvePath } from "node:path";
10
10
  import { parse as yamlParse } from "yaml";
11
+ import { createLogger } from "../logger.js";
11
12
  import { parseEnvContent, parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
12
13
  import { assertNoSecretLikeStackEnvKeys, isSecretLikeStackEnvKey } from './secrets.js';
13
14
  import { ensureSecret } from './secrets-files.js';
14
15
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
15
16
  import { listEnabledAddonIds } from "./registry.js";
16
- import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
17
- import { SPEC_DEFAULTS } from "./stack-spec.js";
17
+ import { resolveOperatorIds, hasUsableOperatorId, type OperatorIds } from "./operator-ids.js";
18
+ import { SPEC_DEFAULTS } from "./defaults.js";
19
+ import { CURRENT_LAYOUT_VERSION } from "./migrations.js";
18
20
 
19
21
  import {
20
22
  readCoreCompose,
@@ -25,6 +27,8 @@ import { sha256, randomHex } from "./crypto.js";
25
27
 
26
28
  const DEFAULT_IMAGE_TAG = "latest";
27
29
 
30
+ const logger = createLogger("config-persistence");
31
+
28
32
  // ── Env File Management ──────────────────────────────────────────────
29
33
 
30
34
  /**
@@ -128,6 +132,12 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
128
132
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
129
133
  `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
130
134
  "",
135
+ "# ── Layout (on-disk schema version; managed by the migration harness) ──",
136
+ `OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`,
137
+ "",
138
+ "# ── Enabled addons (comma-separated; managed via the Add-ons UI / CLI) ──",
139
+ "OP_ENABLED_ADDONS=",
140
+ "",
131
141
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
132
142
  "# Guardian is network-only (no host port) — channels reach it via",
133
143
  "# http://guardian:8080 over the channel_lan Docker network.",
@@ -216,6 +226,13 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
216
226
  const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
217
227
  if (composeFiles.length === 0) return;
218
228
 
229
+ // Resolve the operator UID/GID compose runs containers as (`user:`), so we
230
+ // can chown the dirs we pre-create to match. Without this, dirs created by
231
+ // a root-running install (or a host UID that differs from the forced
232
+ // container UID) are unwritable inside the non-root container — on OrbStack
233
+ // real UIDs are preserved, so e.g. ollama's mkdir is denied (issue #452).
234
+ const operatorIds = resolveOperatorIds(state.homeDir);
235
+
219
236
  const envVars: Record<string, string> = {
220
237
  ...(process.env as Record<string, string>),
221
238
  ...parseEnvFile(`${state.stashDir}/env/stack.env`),
@@ -257,16 +274,40 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
257
274
  const isFile = basename.includes('.');
258
275
 
259
276
  if (isFile) {
260
- mkdirSync(dirname(resolvedHostPath), { recursive: true });
277
+ const parent = dirname(resolvedHostPath);
278
+ mkdirSync(parent, { recursive: true });
261
279
  writeFileSync(resolvedHostPath, '');
280
+ chownVolumeTarget(parent, operatorIds);
281
+ chownVolumeTarget(resolvedHostPath, operatorIds);
262
282
  } else {
263
283
  mkdirSync(resolvedHostPath, { recursive: true });
284
+ chownVolumeTarget(resolvedHostPath, operatorIds);
264
285
  }
265
286
  }
266
287
  }
267
288
  }
268
289
  }
269
290
 
291
+ /**
292
+ * chown a just-created bind-mount target to the operator UID/GID so the
293
+ * non-root container (`user: ${OP_UID}:${OP_GID}`) can write to it.
294
+ *
295
+ * No-op on Windows (chown is meaningless there) or when no operator can be
296
+ * resolved. A failure (e.g. not the owner) is logged and swallowed — the
297
+ * mkdir already succeeded and Docker Desktop's gRPC-FUSE masks ownership
298
+ * anyway, so a chown failure must not abort the install.
299
+ */
300
+ function chownVolumeTarget(path: string, operatorIds: OperatorIds | null): void {
301
+ if (process.platform === "win32" || !operatorIds) return;
302
+ try {
303
+ chownSync(path, operatorIds.uid, operatorIds.gid);
304
+ } catch (error) {
305
+ logger.warn(
306
+ `Could not chown volume target ${path} to ${operatorIds.uid}:${operatorIds.gid}: ${error instanceof Error ? error.message : String(error)}`
307
+ );
308
+ }
309
+ }
310
+
270
311
  // ── Persistence (direct-write to live paths) ────────────────────────
271
312
 
272
313
  export function writeRuntimeFiles(
@@ -56,18 +56,25 @@ export function ensureOpenCodeSystemConfig(): void {
56
56
 
57
57
  const REPO = "itlackey/openpalm";
58
58
 
59
- function resolveAssetVersion(): string {
60
- if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
61
- try {
62
- const pkgJson = JSON.parse(
63
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
59
+ // The version to download assets for is ALWAYS passed in by the caller (the
60
+ // upgrade flow resolves the canonical platform tag — the newest published
61
+ // `openpalm/assistant` Docker tag, e.g. "v0.11.0-rc.6" — and threads it here).
62
+ // This module intentionally does NOT resolve the version itself: no env var, no
63
+ // `import.meta.url` package.json read (which breaks when the lib is bundled into
64
+ // the UI/electron), and never a silent "main" fallback (main's asset layout can
65
+ // differ from a released install). Bundler-agnostic by construction.
66
+
67
+ function normalizeAssetRef(version: string): string {
68
+ const v = version.trim();
69
+ if (!v) {
70
+ throw new Error(
71
+ "Cannot download OpenPalm stack assets: no version provided. " +
72
+ "The caller must pass the target release tag (e.g. \"v0.11.0-rc.6\")."
64
73
  );
65
- return `v${pkgJson.version}`;
66
- } catch {
67
- return "main";
68
74
  }
75
+ // GitHub release/raw refs are `vX.Y.Z`; accept a bare semver and add the `v`.
76
+ return /^\d/.test(v) ? `v${v}` : v;
69
77
  }
70
- const VERSION = resolveAssetVersion();
71
78
 
72
79
  // Persona files (openpalm.md, system.md), stash seeds, and user-editable config
73
80
  // files are intentionally NOT in this list. They are seeded once (never
@@ -85,9 +92,9 @@ const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
85
92
  { relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
86
93
  ];
87
94
 
88
- async function downloadAsset(filename: string): Promise<string> {
89
- const releaseUrl = `https://github.com/${REPO}/releases/download/${VERSION}/${filename}`;
90
- const rawUrl = `https://raw.githubusercontent.com/${REPO}/${VERSION}/${filename}`;
95
+ async function downloadAsset(filename: string, version: string): Promise<string> {
96
+ const releaseUrl = `https://github.com/${REPO}/releases/download/${version}/${filename}`;
97
+ const rawUrl = `https://raw.githubusercontent.com/${REPO}/${version}/${filename}`;
91
98
 
92
99
  for (const url of [releaseUrl, rawUrl]) {
93
100
  try {
@@ -97,19 +104,20 @@ async function downloadAsset(filename: string): Promise<string> {
97
104
  // try next URL
98
105
  }
99
106
  }
100
- throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${VERSION}")`);
107
+ throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${version}")`);
101
108
  }
102
109
 
103
- export async function refreshCoreAssets(): Promise<{
110
+ export async function refreshCoreAssets(version: string): Promise<{
104
111
  backupDir: string | null;
105
112
  updated: string[];
106
113
  }> {
114
+ const ref = normalizeAssetRef(version);
107
115
  const homeDir = resolveOpenPalmHome();
108
116
  const updated: string[] = [];
109
117
  let backupDir: string | null = null;
110
118
 
111
119
  for (const asset of MANAGED_ASSETS) {
112
- const freshContent = await downloadAsset(asset.githubFilename);
120
+ const freshContent = await downloadAsset(asset.githubFilename, ref);
113
121
  const targetPath = join(homeDir, asset.relPath);
114
122
 
115
123
  if (existsSync(targetPath)) {
@@ -135,7 +143,7 @@ export async function refreshCoreAssets(): Promise<{
135
143
  for (const asset of SEEDED_ASSETS) {
136
144
  const targetPath = join(homeDir, asset.relPath);
137
145
  if (existsSync(targetPath)) continue;
138
- const freshContent = await downloadAsset(asset.githubFilename);
146
+ const freshContent = await downloadAsset(asset.githubFilename, ref);
139
147
  mkdirSync(dirname(targetPath), { recursive: true });
140
148
  writeFileSync(targetPath, freshContent);
141
149
  updated.push(asset.relPath);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Stack defaults (ports + image). Formerly in stack-spec.ts; kept after the
3
+ * stack.yml removal because these are the canonical fallback values used when a
4
+ * key is absent from stack.env.
5
+ */
6
+ export const SPEC_DEFAULTS = {
7
+ ports: {
8
+ assistant: 3800,
9
+ hostUi: 3880,
10
+ assistantSsh: 2222,
11
+ },
12
+ image: {
13
+ namespace: "openpalm",
14
+ tag: "latest",
15
+ },
16
+ } as const;
@@ -82,6 +82,21 @@ export function upsertEnvValue(content: string, key: string, value: string): str
82
82
  return `${content}${suffix}${line}\n`;
83
83
  }
84
84
 
85
+ /** Addon name shape (matches the former stack.yml validation). */
86
+ export const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
87
+
88
+ /**
89
+ * Parse the `OP_ENABLED_ADDONS` stack.env value (comma-separated) into a
90
+ * validated, de-duplicated, sorted list of addon ids. Replaces the former
91
+ * stack.yml `addons[]` array as the authoritative enabled-addon record.
92
+ */
93
+ export function parseEnabledAddons(value: string | undefined): string[] {
94
+ if (!value) return [];
95
+ return [...new Set(
96
+ value.split(',').map((v) => v.trim()).filter((v) => ADDON_NAME_RE.test(v)),
97
+ )].sort();
98
+ }
99
+
85
100
  export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
86
101
 
87
102
  /**
@@ -0,0 +1,114 @@
1
+ // Host GPU / VRAM detection for setup recommendations.
2
+ //
3
+ // Data-driven on purpose: each entry in GPU_PROBES is a vendor + a command to
4
+ // run + a pure parser. Adding a new accelerator (Intel Arc, Apple Metal, a new
5
+ // rocm/CUDA query, etc.) is a one-entry change here — nothing downstream needs to
6
+ // know. detectGpu() runs every probe, ignores the ones whose tool is absent, and
7
+ // returns the single best (highest-VRAM) result, or null when no GPU is found.
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { createLogger } from "../logger.js";
11
+
12
+ const logger = createLogger("hardware-detect");
13
+
14
+ export type GpuVendor = "nvidia" | "amd" | "unknown";
15
+
16
+ export type GpuInfo = {
17
+ vendor: GpuVendor;
18
+ /** Human-readable adapter name, e.g. "NVIDIA GeForce RTX 4090". */
19
+ name: string;
20
+ /** Total VRAM in MiB. 0 when the tool reported the GPU but not its memory. */
21
+ vramMb: number;
22
+ };
23
+
24
+ type GpuProbe = {
25
+ vendor: GpuVendor;
26
+ command: string;
27
+ args: string[];
28
+ /** Pure parser: tool stdout -> detected GPUs. Must not throw. */
29
+ parse: (stdout: string) => GpuInfo[];
30
+ };
31
+
32
+ /** Parse `nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits`. */
33
+ export function parseNvidiaSmi(stdout: string): GpuInfo[] {
34
+ return stdout
35
+ .split("\n")
36
+ .map((line) => line.trim())
37
+ .filter(Boolean)
38
+ .map((line): GpuInfo | null => {
39
+ // "NVIDIA GeForce RTX 4090, 24564"
40
+ const idx = line.lastIndexOf(",");
41
+ if (idx === -1) return null;
42
+ const name = line.slice(0, idx).trim();
43
+ const vramMb = Number.parseInt(line.slice(idx + 1).trim(), 10);
44
+ if (!name || !Number.isFinite(vramMb)) return null;
45
+ return { vendor: "nvidia", name, vramMb };
46
+ })
47
+ .filter((g): g is GpuInfo => g !== null);
48
+ }
49
+
50
+ /** Parse `rocm-smi --showmeminfo vram --showproductname --json`. */
51
+ export function parseRocmSmi(stdout: string): GpuInfo[] {
52
+ let doc: Record<string, Record<string, string>>;
53
+ try {
54
+ doc = JSON.parse(stdout);
55
+ } catch {
56
+ return [];
57
+ }
58
+ const out: GpuInfo[] = [];
59
+ for (const card of Object.values(doc)) {
60
+ if (!card || typeof card !== "object") continue;
61
+ // rocm-smi key names drift across versions — match loosely.
62
+ const vramKey = Object.keys(card).find((k) => /vram total memory/i.test(k));
63
+ const nameKey = Object.keys(card).find((k) => /product name|card series|gfx/i.test(k));
64
+ const bytes = vramKey ? Number.parseInt(String(card[vramKey]).trim(), 10) : NaN;
65
+ const vramMb = Number.isFinite(bytes) ? Math.round(bytes / (1024 * 1024)) : 0;
66
+ out.push({ vendor: "amd", name: nameKey ? String(card[nameKey]).trim() : "AMD GPU", vramMb });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ const GPU_PROBES: GpuProbe[] = [
72
+ {
73
+ vendor: "nvidia",
74
+ command: "nvidia-smi",
75
+ args: ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
76
+ parse: parseNvidiaSmi,
77
+ },
78
+ {
79
+ vendor: "amd",
80
+ command: "rocm-smi",
81
+ args: ["--showmeminfo", "vram", "--showproductname", "--json"],
82
+ parse: parseRocmSmi,
83
+ },
84
+ ];
85
+
86
+ function run(command: string, args: string[], timeoutMs = 3_000): Promise<string | null> {
87
+ return new Promise((resolve) => {
88
+ execFile(command, args, { timeout: timeoutMs }, (err, stdout) => {
89
+ // ENOENT (tool not installed) and any non-zero exit -> not available.
90
+ resolve(err ? null : stdout?.toString() ?? "");
91
+ });
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Detect the host's best GPU. Returns the highest-VRAM adapter across all probes,
97
+ * or null when none is found. Never throws.
98
+ */
99
+ export async function detectGpu(): Promise<GpuInfo | null> {
100
+ const found: GpuInfo[] = [];
101
+ await Promise.all(
102
+ GPU_PROBES.map(async (probe) => {
103
+ const stdout = await run(probe.command, probe.args);
104
+ if (stdout === null) return;
105
+ try {
106
+ found.push(...probe.parse(stdout));
107
+ } catch (error) {
108
+ logger.debug("gpu probe parse failed", { vendor: probe.vendor, error: String(error) });
109
+ }
110
+ }),
111
+ );
112
+ if (found.length === 0) return null;
113
+ return found.reduce((best, g) => (g.vramMb > best.vramMb ? g : best));
114
+ }
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Single ~/.openpalm/ root:
5
5
  * config/ — user-editable config + system config files (akm/)
6
- * config/stack/ — compose runtime + stack config (stack.env, stack.yml, auth.json, fixed compose files)
6
+ * config/stack/ — fixed compose files (no stack.env/secrets/stack.yml)
7
7
  * data/ — persistent service data, logs, backups, rollback
8
- * knowledge/ — akm knowledge (env, secrets, tasks)
8
+ * knowledge/ — akm knowledge (env, secrets, tasks); env/stack.env is the
9
+ * authoritative stack composition + versions record
9
10
  * workspace/ — shared assistant work area
10
- * config/stack/ — compose runtime assets + stack config (stack.env, stack.yml)
11
11
  */
12
12
  import { mkdirSync } from "node:fs";
13
13
  import { homedir, tmpdir } from "node:os";
@@ -26,7 +26,6 @@ import {
26
26
  } from "./setup.js";
27
27
  import type { SetupSpec, SetupConnection } from "./setup.js";
28
28
  import type { ControlPlaneState } from "./types.js";
29
- import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
30
29
  import { readSecret } from './secrets-files.js';
31
30
 
32
31
  // ── Helpers ──────────────────────────────────────────────────────────────
@@ -313,11 +312,6 @@ describe("Existing Install", () => {
313
312
  })
314
313
  );
315
314
 
316
- // stack.yml is just a version marker now
317
- const specAfterSecond = readStackSpec(stackDir);
318
- expect(specAfterSecond).not.toBeNull();
319
- expect(specAfterSecond!.version).toBe(2);
320
-
321
315
  const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
322
316
  expect(auth.groq.key).toBe("gsk-test-key-456");
323
317
  });
@@ -426,12 +420,6 @@ describe("Broken/Corrupt State", () => {
426
420
  }
427
421
  });
428
422
 
429
- // Scenario 13: Missing stack.yml returns null
430
- it("readStackSpec returns null when stack.yml missing", () => {
431
- const spec = readStackSpec(stackDir);
432
- expect(spec).toBeNull();
433
- });
434
-
435
423
  // Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
436
424
  it("performSetup creates missing subdirectories", async () => {
437
425
  // Seed the minimal env files first
@@ -453,16 +441,6 @@ describe("Broken/Corrupt State", () => {
453
441
  expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
454
442
  });
455
443
 
456
- // Scenario 15: openpalm.yaml with old version
457
- it("readStackSpec returns null for version 1 spec", () => {
458
- writeFileSync(
459
- join(stackDir, STACK_SPEC_FILENAME),
460
- "version: 1\nconnections: []\n"
461
- );
462
-
463
- const spec = readStackSpec(stackDir);
464
- expect(spec).toBeNull();
465
- });
466
444
  });
467
445
 
468
446
  // =====================================================================
@@ -562,10 +540,6 @@ describe("Setup Input Variations", () => {
562
540
 
563
541
  const result = await performSetup(input);
564
542
  expect(result.ok).toBe(true);
565
-
566
- const spec = readStackSpec(stackDir);
567
- expect(spec).not.toBeNull();
568
- expect(spec!.version).toBe(2);
569
543
  });
570
544
 
571
545
  // Scenario 21: Multiple providers map to correct env vars
@@ -626,12 +600,9 @@ describe("performSetup end-to-end artifacts", () => {
626
600
  rmSync(homeDir, { recursive: true, force: true });
627
601
  });
628
602
 
629
- it("writes stack.yml and readStackSpec returns v2", async () => {
603
+ it("does not create a stack.yml (addon state lives in stack.env)", async () => {
630
604
  await performSetup(makeValidSpec());
631
-
632
- const spec = readStackSpec(stackDir);
633
- expect(spec).not.toBeNull();
634
- expect(spec!.version).toBe(2);
605
+ expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
635
606
  });
636
607
 
637
608
  it("writes akm config with embedding dims from setup spec", async () => {
@@ -172,10 +172,7 @@ function resolveNewestDockerTag(payload: unknown): string | null {
172
172
  return fallback;
173
173
  }
174
174
 
175
- export async function updateStackEnvToLatestImageTag(state: ControlPlaneState): Promise<{
176
- namespace: string;
177
- tag: string;
178
- }> {
175
+ function resolveImageNamespace(state: ControlPlaneState): string {
179
176
  const systemEnvPath = `${state.stashDir}/env/stack.env`;
180
177
  const parsed = parseEnvFile(systemEnvPath);
181
178
  const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
@@ -183,11 +180,21 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
183
180
  if (!IMAGE_NAMESPACE_RE.test(namespace)) {
184
181
  throw new Error(`Invalid image namespace in system.env: ${namespace}`);
185
182
  }
183
+ return namespace;
184
+ }
186
185
 
187
- // `assistant` is the version-of-record image: all platform images
188
- // (assistant, guardian, channel, voice) are published in lockstep under the
189
- // same OP_IMAGE_TAG, so its newest tag is the canonical platform version.
190
-
186
+ /**
187
+ * Resolve the newest published platform tag from the Docker registry.
188
+ *
189
+ * `assistant` is the version-of-record image: all platform images
190
+ * (assistant, guardian, channel, voice) are published in lockstep under the
191
+ * same OP_IMAGE_TAG, so its newest tag is the canonical platform version.
192
+ *
193
+ * Used both to auto-detect during "Update now" and to resolve a requested
194
+ * `latest` selection into a concrete release tag before fetching stack assets
195
+ * (GitHub has no asset tree at a `latest` ref).
196
+ */
197
+ export async function resolveLatestPlatformTag(namespace: string): Promise<string> {
191
198
  let response: Response;
192
199
  try {
193
200
  response = await fetch(
@@ -207,6 +214,16 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
207
214
  if (!latestTag) {
208
215
  throw new Error("No usable Docker image tag found");
209
216
  }
217
+ return latestTag;
218
+ }
219
+
220
+ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState): Promise<{
221
+ namespace: string;
222
+ tag: string;
223
+ }> {
224
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
225
+ const namespace = resolveImageNamespace(state);
226
+ const latestTag = await resolveLatestPlatformTag(namespace);
210
227
 
211
228
  const currentContent = existsSync(systemEnvPath) ? readFileSync(systemEnvPath, "utf-8") : "";
212
229
  const updatedContent = mergeEnvContent(currentContent, { OP_IMAGE_TAG: latestTag }, { uncomment: true });
@@ -216,7 +233,9 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
216
233
  }
217
234
 
218
235
  export async function applyUpgrade(
219
- state: ControlPlaneState
236
+ state: ControlPlaneState,
237
+ /** Release tag whose stack assets to fetch (e.g. "v0.11.0-rc.6"). Caller-supplied. */
238
+ version: string
220
239
  ): Promise<{
221
240
  backupDir: string | null;
222
241
  updated: string[];
@@ -225,7 +244,7 @@ export async function applyUpgrade(
225
244
  const lock = acquireInstallLock(state.dataDir);
226
245
  if (!lock) throw new Error("Another install is already in progress");
227
246
  try {
228
- const { backupDir, updated } = await refreshCoreAssets();
247
+ const { backupDir, updated } = await refreshCoreAssets(version);
229
248
  const restarted = await reconcileCore(state, {});
230
249
  return { backupDir, updated, restarted };
231
250
  } finally {
@@ -269,7 +288,9 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
269
288
  const tagResult = await updateStackEnvToLatestImageTag(state);
270
289
  imageTag = tagResult.tag;
271
290
  namespace = tagResult.namespace;
272
- upgradeResult = await applyUpgrade(state);
291
+ // The resolved platform tag IS the version whose stack assets we fetch —
292
+ // keeps compose files and images in lockstep.
293
+ upgradeResult = await applyUpgrade(state, imageTag);
273
294
  } catch (e) {
274
295
  // Restore stack.env on failure
275
296
  if (originalStackEnv !== null) {
@@ -284,9 +305,14 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
284
305
  throw new Error(`Failed to pull images: ${pullResult.stderr}`);
285
306
  }
286
307
 
287
- // 4. Recreate containers (includes profiles for voice addon)
308
+ // 4. Recreate containers (includes profiles for voice addon).
309
+ // forceRecreate is REQUIRED: channel adapters are installed at container
310
+ // startup from npm dist-tags (CHANNEL_PACKAGE, e.g. @openpalm/channel-discord@latest),
311
+ // so an unchanged compose config would leave those containers running on the
312
+ // old adapter. --force-recreate guarantees guardian + channel containers
313
+ // restart and re-resolve their dist-tag adapters (issue #450).
288
314
  const services = await buildManagedServices(state);
289
- const upResult = await composeUp({ ...composeOpts, services, removeOrphans: true });
315
+ const upResult = await composeUp({ ...composeOpts, services, forceRecreate: true, removeOrphans: true });
290
316
  if (!upResult.ok) {
291
317
  throw new Error(`Images pulled but failed to recreate containers: ${upResult.stderr}`);
292
318
  }
@@ -305,13 +331,34 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
305
331
  * Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
306
332
  */
307
333
  export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
334
+ const namespace = resolveImageNamespace(state);
335
+
336
+ // "latest" (or an empty selection) is not a real GitHub ref — there are no
337
+ // `.openpalm/...` stack assets at a `latest` tag, so refreshCoreAssets would
338
+ // fail with a raw download error. Resolve it to the concrete newest published
339
+ // platform tag BEFORE writing the env or fetching assets, so images and
340
+ // stack assets stay in lockstep on a real release tag.
341
+ const requested = tag.trim();
342
+ let resolvedTag = requested;
343
+ if (requested === "" || requested.toLowerCase() === "latest") {
344
+ try {
345
+ resolvedTag = await resolveLatestPlatformTag(namespace);
346
+ } catch (e) {
347
+ const msg = e instanceof Error ? e.message : String(e);
348
+ throw new Error(
349
+ `Cannot resolve "latest" to a concrete release: ${msg}. ` +
350
+ "Check your network connection or select a specific version."
351
+ );
352
+ }
353
+ }
354
+
308
355
  const stackEnvPath = `${state.stashDir}/env/stack.env`;
309
356
  const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
310
- writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
311
- const upgradeResult = await applyUpgrade(state);
357
+ writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: resolvedTag }, { uncomment: true }));
358
+ const upgradeResult = await applyUpgrade(state, resolvedTag);
312
359
  return {
313
- imageTag: tag,
314
- namespace: "openpalm",
360
+ imageTag: resolvedTag,
361
+ namespace,
315
362
  backupDir: upgradeResult.backupDir,
316
363
  assetsUpdated: upgradeResult.updated,
317
364
  restarted: upgradeResult.restarted,
@@ -325,20 +372,27 @@ export function buildComposeFileList(state: ControlPlaneState): string[] {
325
372
  export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
326
373
  const composeOpts = buildComposeOptions(state);
327
374
 
375
+ // Always force-recreate the core services (assistant + guardian) on upgrade,
376
+ // regardless of how the service set is discovered. getAddonServiceNames
377
+ // deliberately EXCLUDES guardian, so a fallback that relied on it alone would
378
+ // drop guardian from the recreated set when channel profiles are active —
379
+ // leaving guardian on stale state (issue #450).
380
+ const services = new Set<string>(CORE_SERVICES);
381
+
328
382
  // Prefer compose-derived service list when Docker is available
329
383
  if (composeOpts.files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
330
384
  const result = await composeConfigServices(composeOpts);
331
385
  if (result.ok && result.services.length > 0) {
332
- return result.services;
386
+ for (const s of result.services) services.add(s);
387
+ return [...services];
333
388
  }
334
389
  }
335
390
 
336
391
  // Fallback: static inference from CORE_SERVICES + active addon overlays
337
- const services: string[] = [...CORE_SERVICES];
338
392
  for (const addon of listEnabledAddonIds(state.homeDir)) {
339
- services.push(...getAddonServiceNames(state.homeDir, addon));
393
+ for (const s of getAddonServiceNames(state.homeDir, addon)) services.add(s);
340
394
  }
341
- return services;
395
+ return [...services];
342
396
  }
343
397
 
344
398