@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.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.
Files changed (63) 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 +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. package/src/control-plane/registry-components.test.ts +0 -391
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Registry catalog discovery and refresh.
2
+ * Built-in addon/profile discovery and legacy registry helpers.
3
3
  *
4
- * `OP_HOME/state/registry` is the only persistent catalog location.
5
- * Install seeds it once; refresh replaces it explicitly.
4
+ * Runtime addon enablement is recorded in stack.yml and resolved to Compose
5
+ * profiles. The fixed compose files under config/stack are the runtime source
6
+ * of truth.
6
7
  */
7
8
  import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
9
  import { execFile, execFileSync } from 'node:child_process';
@@ -10,19 +11,164 @@ import { join } from 'node:path';
10
11
  import { tmpdir } from 'node:os';
11
12
  import { parse as parseYaml } from 'yaml';
12
13
  import { createLogger } from '../logger.js';
13
- import { isChannelAddon } from './channels.js';
14
- import { randomHex, writeChannelSecrets } from './config-persistence.js';
14
+ import { resolveLocalOpenpalmDir } from './ui-assets.js';
15
+ import { ensureChannelSecret } from './config-persistence.js';
15
16
  import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
17
+ import { readBundledStackAsset } from './core-assets.js';
18
+ import { canonicalAddonProfileSelection, resolveHardwareProfileVariant } from './profile-ids.js';
19
+ import { listStackSpecAddons, setStackSpecAddon } from './stack-spec.js';
20
+ import type { ControlPlaneState } from './types.js';
16
21
  import {
17
22
  resolveRegistryAddonsDir,
18
23
  resolveRegistryAutomationsDir,
19
24
  resolveRegistryDir,
25
+ resolveStashDir,
20
26
  } from './home.js';
21
27
 
22
28
  const BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/;
23
29
  const URL_RE = /^(https:\/\/|git@)/;
24
30
  const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
25
31
  const logger = createLogger('registry');
32
+ const BUILTIN_ADDONS = ['api', 'chat', 'discord', 'ollama', 'slack', 'ssh', 'voice'] as const;
33
+
34
+ // Credential/config field definitions for the first-party addons, parsed by the
35
+ // admin Secrets/Addons UI (`# @sensitive` → password+masked, `KEY=DEFAULT`).
36
+ // The file-based registry was removed from the skeleton, so these live in-code.
37
+ // `ssh` is compose/profile-only (no configurable env) and is intentionally absent.
38
+ const BUILTIN_ADDON_ENV_SCHEMAS: Record<string, string> = {
39
+ api: `# API Gateway channel configuration
40
+ # ---
41
+
42
+ # HMAC secret for the API channel. Auto-generated during setup if left blank;
43
+ # stored as knowledge/secrets/channel_api_secret.
44
+ # @required @sensitive
45
+ CHANNEL_API_SECRET=
46
+ `,
47
+ chat: `# Web Chat channel configuration
48
+ # ---
49
+
50
+ # HMAC secret for the chat channel. Auto-generated during setup if left blank;
51
+ # stored as knowledge/secrets/channel_chat_secret.
52
+ # @required @sensitive
53
+ CHANNEL_CHAT_SECRET=
54
+ `,
55
+ discord: `# Discord bot configuration
56
+ # ---
57
+
58
+ # HMAC secret for the Discord channel. Auto-generated during setup if left blank.
59
+ # @required @sensitive
60
+ CHANNEL_DISCORD_SECRET=
61
+
62
+ # ---
63
+ # Discord credentials
64
+ # ---
65
+
66
+ # Application ID from the Discord Developer Portal.
67
+ # https://discord.com/developers/applications
68
+ # @required
69
+ DISCORD_APPLICATION_ID=
70
+
71
+ # Bot token from the Discord Developer Portal (Bot → Token).
72
+ # @required @sensitive
73
+ DISCORD_BOT_TOKEN=
74
+
75
+ # ---
76
+ # Access control
77
+ # ---
78
+
79
+ # Comma-separated allowed guild (server) IDs. Empty = all joined guilds.
80
+ DISCORD_ALLOWED_GUILDS=
81
+
82
+ # Comma-separated allowed role IDs.
83
+ DISCORD_ALLOWED_ROLES=
84
+
85
+ # Comma-separated allowed user IDs.
86
+ DISCORD_ALLOWED_USERS=
87
+
88
+ # Comma-separated blocked user IDs (denied even if otherwise allowed).
89
+ DISCORD_BLOCKED_USERS=
90
+
91
+ # ---
92
+ # Behavior
93
+ # ---
94
+
95
+ # Register slash commands on startup.
96
+ DISCORD_REGISTER_COMMANDS=true
97
+
98
+ # JSON array of custom slash command definitions.
99
+ DISCORD_CUSTOM_COMMANDS=
100
+
101
+ # Hours before a conversation thread expires.
102
+ DISCORD_THREAD_TTL_HOURS=24
103
+
104
+ # Milliseconds to wait before forwarding a message (0 = immediate).
105
+ DISCORD_FORWARD_TIMEOUT_MS=0
106
+ `,
107
+ slack: `# Slack bot configuration
108
+ # ---
109
+
110
+ # HMAC secret for the Slack channel. Auto-generated during setup if left blank.
111
+ # @required @sensitive
112
+ CHANNEL_SLACK_SECRET=
113
+
114
+ # ---
115
+ # Slack credentials
116
+ # ---
117
+
118
+ # Bot User OAuth Token (OAuth & Permissions → Bot User OAuth Token).
119
+ # @required @sensitive
120
+ SLACK_BOT_TOKEN=
121
+
122
+ # App-Level Token with connections:write (Basic Information → App-Level Tokens).
123
+ # @required @sensitive
124
+ SLACK_APP_TOKEN=
125
+
126
+ # ---
127
+ # Access control
128
+ # ---
129
+
130
+ # Comma-separated allowed channel IDs. Empty = all channels the bot is in.
131
+ SLACK_ALLOWED_CHANNELS=
132
+
133
+ # Comma-separated allowed user IDs.
134
+ SLACK_ALLOWED_USERS=
135
+
136
+ # Comma-separated blocked user IDs.
137
+ SLACK_BLOCKED_USERS=
138
+
139
+ # ---
140
+ # Behavior
141
+ # ---
142
+
143
+ # Hours before a conversation thread expires.
144
+ SLACK_THREAD_TTL_HOURS=24
145
+
146
+ # Milliseconds to allow for guardian forwarding before timing out (default 30m).
147
+ SLACK_FORWARD_TIMEOUT_MS=1800000
148
+ `,
149
+ ollama: `# Ollama component configuration
150
+ # ---
151
+
152
+ # Bind address for the Ollama HTTP API (default: localhost only).
153
+ # @required
154
+ OP_OLLAMA_BIND_ADDRESS=127.0.0.1
155
+ `,
156
+ voice: `# OpenPalm Voice (Kokoro TTS + Whisper STT) configuration
157
+ # ---
158
+ # Local inference server — no upstream API or key. Values are optional; the
159
+ # compose overlay supplies safe defaults.
160
+
161
+ # faster-whisper model id. Default base.en is baked into the image.
162
+ # @required
163
+ OP_VOICE_WHISPER_MODEL=base.en
164
+
165
+ # Default Kokoro voice id (54 bundled voices, e.g. af_heart, am_michael).
166
+ OP_VOICE_KOKORO_VOICE=bf_isabella
167
+
168
+ # Python logging level: debug, info, warning, error.
169
+ OP_VOICE_LOG_LEVEL=info
170
+ `,
171
+ };
26
172
 
27
173
  let warnedMissingRegistryAddonsDir = false;
28
174
 
@@ -107,8 +253,8 @@ function countValidAutomations(rootDir: string): number {
107
253
  const automationsDir = join(rootDir, 'automations');
108
254
  if (!existsSync(automationsDir)) return 0;
109
255
  return readdirSync(automationsDir).filter((file) => {
110
- if (!file.endsWith('.md')) return false;
111
- return isValidComponentName(file.replace(/\.md$/, ''));
256
+ if (!file.endsWith('.yml')) return false;
257
+ return isValidComponentName(file.replace(/\.yml$/, ''));
112
258
  }).length;
113
259
  }
114
260
 
@@ -127,8 +273,8 @@ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryC
127
273
  }
128
274
 
129
275
  export function materializeRegistryCatalog(sourceRoot: string): string {
130
- const sourceAddonsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons');
131
- const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
276
+ const sourceAddonsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons');
277
+ const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
132
278
  const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
133
279
 
134
280
  try {
@@ -205,29 +351,28 @@ export function discoverRegistryComponents(): Record<string, RegistryComponentEn
205
351
  }
206
352
 
207
353
  export function discoverRegistryAutomations(stashDir: string): RegistryAutomationEntry[] {
208
- const automationsDir = resolveRegistryAutomationsDir();
354
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
355
+ const automationsDir = localOpenpalmDir
356
+ ? join(localOpenpalmDir, 'knowledge', 'tasks')
357
+ : join(stashDir, 'tasks');
209
358
  if (!existsSync(automationsDir)) return [];
210
359
 
211
360
  return readdirSync(automationsDir)
212
- .filter((file) => file.endsWith('.md'))
361
+ .filter((file) => file.endsWith('.yml'))
213
362
  .map((file) => {
214
- const name = file.replace(/\.md$/, '');
363
+ const name = file.replace(/\.yml$/, '');
215
364
  if (!VALID_NAME_RE.test(name)) return null;
216
365
 
217
366
  const content = readFileSync(join(automationsDir, file), 'utf-8');
218
367
  let description = '';
219
368
  let schedule = '';
220
369
 
221
- // Extract frontmatter metadata (between --- delimiters)
370
+ // Extract YAML metadata.
222
371
  try {
223
- const after = content.startsWith('---') ? content.slice(3) : '';
224
- const end = after.indexOf('\n---');
225
- if (end !== -1) {
226
- const parsed = parseYaml(after.slice(0, end));
227
- if (parsed && typeof parsed === 'object') {
228
- description = (parsed as Record<string, unknown>).description as string ?? '';
229
- schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
230
- }
372
+ const parsed = parseYaml(content);
373
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
374
+ description = (parsed as Record<string, unknown>).description as string ?? '';
375
+ schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
231
376
  }
232
377
  } catch {
233
378
  // best-effort metadata extraction
@@ -246,75 +391,75 @@ export function discoverRegistryAutomations(stashDir: string): RegistryAutomatio
246
391
 
247
392
  export function getRegistryAutomation(name: string): string | null {
248
393
  if (!VALID_NAME_RE.test(name)) return null;
249
- const mdPath = join(resolveRegistryAutomationsDir(), `${name}.md`);
250
- if (!existsSync(mdPath)) return null;
251
- return readFileSync(mdPath, 'utf-8');
394
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
395
+ const candidates = [
396
+ localOpenpalmDir ? join(localOpenpalmDir, 'knowledge', 'tasks', `${name}.yml`) : '',
397
+ join(resolveStashDir(), 'tasks', `${name}.yml`),
398
+ join(resolveRegistryAutomationsDir(), `${name}.yml`),
399
+ ].filter(Boolean);
400
+ for (const ymlPath of candidates) {
401
+ if (existsSync(ymlPath)) return readFileSync(ymlPath, 'utf-8');
402
+ }
403
+ return null;
252
404
  }
253
405
 
254
- export function getRegistryAddonConfig(homeDir: string, name: string): RegistryAddonConfig {
406
+ export function getRegistryAddonConfig(_homeDir: string, name: string): RegistryAddonConfig {
255
407
  if (!VALID_NAME_RE.test(name)) {
256
408
  throw new Error(`Invalid addon name: ${name}`);
257
409
  }
258
410
 
259
- // Overlay-only addons (compose.yml only, no .env.schema) have no env vars
260
- // to render, so the schema reads as an empty string.
261
- const schemaPath = `state/registry/addons/${name}/.env.schema`;
262
- const schemaFile = join(homeDir, schemaPath);
411
+ // Resolve the addon's `.env.schema` (credential/config field definitions):
412
+ // 1. A materialized registry copy at OP_HOME/data/registry/addons (custom
413
+ // addons installed from a registry win).
414
+ // 2. The built-in schema embedded below (the first-party addons). The
415
+ // file-based registry was removed from the skeleton, so the built-in
416
+ // addon credential schemas live in-code rather than as bundled files.
417
+ const materialized = join(resolveRegistryAddonsDir(), name, '.env.schema');
418
+ if (existsSync(materialized)) {
419
+ return { schemaPath: materialized, userEnvPath: 'knowledge/env/stack.env', envSchema: readFileSync(materialized, 'utf-8') };
420
+ }
263
421
  return {
264
- schemaPath,
265
- userEnvPath: 'config/stack/stack.env',
266
- envSchema: existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '',
422
+ schemaPath: '',
423
+ userEnvPath: 'knowledge/env/stack.env',
424
+ envSchema: BUILTIN_ADDON_ENV_SCHEMAS[name] ?? '',
267
425
  };
268
426
  }
269
427
 
270
428
  export function listAvailableAddonIds(): string[] {
271
- const addonsDir = resolveRegistryAddonsDir();
272
- if (!existsSync(addonsDir) && !warnedMissingRegistryAddonsDir) {
273
- warnedMissingRegistryAddonsDir = true;
274
- logger.warn('registry addons directory is missing', { addonsDir });
275
- }
276
- return Object.keys(discoverRegistryComponents()).sort();
429
+ return [...BUILTIN_ADDONS].sort();
277
430
  }
278
431
 
279
432
  export function listEnabledAddonIds(homeDir: string): string[] {
280
- const addonsDir = join(homeDir, 'config', 'stack', 'addons');
281
- if (!existsSync(addonsDir)) return [];
282
-
283
- return readdirSync(addonsDir, { withFileTypes: true })
284
- .filter((entry) => entry.isDirectory() && existsSync(join(addonsDir, entry.name, 'compose.yml')))
285
- .map((entry) => entry.name)
286
- .sort();
287
- }
288
-
289
- function copyAddonFromRegistry(homeDir: string, name: string): void {
290
- if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
291
-
292
- const sourceDir = join(resolveRegistryAddonsDir(), name);
293
- // compose.yml is the only required file. Overlay-only addons may omit
294
- // .env.schema entirely.
295
- if (!existsSync(join(sourceDir, 'compose.yml'))) {
296
- throw new Error(`Addon "${name}" not found in registry`);
433
+ const enabled = new Set(listStackSpecAddons(join(homeDir, 'config', 'stack')));
434
+ const env = readStackEnv(join(homeDir, 'config', 'stack'));
435
+ const profiles = new Set((env.COMPOSE_PROFILES ?? '').split(',').map((p) => p.trim()).filter(Boolean));
436
+ for (const key of ['OP_VOICE_PROFILE', 'OP_OLLAMA_PROFILE']) {
437
+ const profile = env[key]?.trim();
438
+ if (profile) profiles.add(profile);
297
439
  }
298
-
299
- const targetDir = join(homeDir, 'config', 'stack', 'addons', name);
300
- rmSync(targetDir, { recursive: true, force: true });
301
- mkdirSync(join(homeDir, 'config', 'stack', 'addons'), { recursive: true });
302
- cpSync(sourceDir, targetDir, { recursive: true });
303
- }
304
-
305
- function removeEnabledAddon(homeDir: string, name: string): void {
306
- if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
307
- rmSync(join(homeDir, 'config', 'stack', 'addons', name), { recursive: true, force: true });
440
+ for (const profile of profiles) {
441
+ const match = profile.match(/^addon\.([a-z0-9-]+)(?:\.|$)/);
442
+ if (match?.[1]) enabled.add(match[1]);
443
+ }
444
+ return [...enabled].sort();
308
445
  }
309
446
 
310
- function readAddonServiceNames(composePath: string): string[] {
311
- if (!existsSync(composePath)) return [];
312
-
447
+ function readAddonServiceNamesFromContent(composeContent: string, composePath: string, addonName?: string): string[] {
313
448
  try {
314
- const parsed = parseYaml(readFileSync(composePath, "utf-8"));
449
+ const parsed = parseYaml(composeContent);
315
450
  const services = parsed && typeof parsed === "object" ? (parsed as { services?: unknown }).services : undefined;
316
451
  if (!services || typeof services !== "object" || Array.isArray(services)) return [];
317
- return Object.keys(services as Record<string, unknown>);
452
+ const entries = Object.entries(services as Record<string, unknown>);
453
+ if (!addonName) return entries.map(([name]) => name);
454
+ return entries
455
+ .filter(([serviceName, raw]) => {
456
+ if (serviceName === 'guardian') return false;
457
+ if (serviceName === addonName || serviceName.startsWith(`${addonName}-`)) return true;
458
+ if (!raw || typeof raw !== 'object') return false;
459
+ const profiles = (raw as { profiles?: unknown }).profiles;
460
+ return Array.isArray(profiles) && profiles.some((p) => typeof p === 'string' && p.startsWith(`addon.${addonName}`));
461
+ })
462
+ .map(([serviceName]) => serviceName);
318
463
  } catch (error) {
319
464
  logger.warn("failed to parse addon compose services", {
320
465
  composePath,
@@ -324,16 +469,27 @@ function readAddonServiceNames(composePath: string): string[] {
324
469
  }
325
470
  }
326
471
 
472
+ function readAddonServiceNames(composePath: string, addonName?: string): string[] {
473
+ if (!existsSync(composePath)) return [];
474
+ return readAddonServiceNamesFromContent(readFileSync(composePath, "utf-8"), composePath, addonName);
475
+ }
476
+
327
477
  export function getAddonServiceNames(homeDir: string, name: string): string[] {
328
478
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
329
479
 
330
480
  const composeCandidates = [
331
- join(homeDir, "config", "stack", "addons", name, "compose.yml"),
332
- join(homeDir, "state", "registry", "addons", name, "compose.yml"),
481
+ join(homeDir, "config", "stack", "channels.compose.yml"),
482
+ join(homeDir, "config", "stack", "services.compose.yml"),
483
+ join(homeDir, "config", "stack", "custom.compose.yml"),
333
484
  ];
334
485
 
335
486
  for (const composePath of composeCandidates) {
336
- const services = readAddonServiceNames(composePath);
487
+ const services = readAddonServiceNames(composePath, name);
488
+ if (services.length > 0) return services;
489
+ }
490
+
491
+ for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
492
+ const services = readAddonServiceNamesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`, name);
337
493
  if (services.length > 0) return services;
338
494
  }
339
495
 
@@ -418,13 +574,13 @@ function execFileNoThrow(
418
574
  /**
419
575
  * Compute the openpalm/voice image ref for a given GPU variant, matching
420
576
  * the substitution chain in the addon compose file:
421
- * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-v0.11.0}-<variant>}
577
+ * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-latest}-<variant>}
422
578
  */
423
579
  function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
424
580
  const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
425
581
  const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
426
582
  if (explicit) return `${namespace}/voice:${explicit}`;
427
- const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'v0.11.0';
583
+ const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'latest';
428
584
  return `${namespace}/voice:${baseTag}-${variant}`;
429
585
  }
430
586
 
@@ -525,11 +681,12 @@ export async function getAddonProfileAvailability(
525
681
 
526
682
  let result: AddonProfileAvailability;
527
683
  try {
528
- if (profile.id === 'cpu') {
684
+ const variant = resolveHardwareProfileVariant(profile.id);
685
+ if (variant === 'cpu') {
529
686
  result = { available: true };
530
- } else if (profile.id === 'cuda') {
687
+ } else if (variant === 'cuda') {
531
688
  result = await probeCuda();
532
- } else if (profile.id === 'rocm') {
689
+ } else if (variant === 'rocm') {
533
690
  result = await probeRocm();
534
691
  } else {
535
692
  // Unknown profile id — assume available; caller is responsible for
@@ -564,12 +721,10 @@ export async function annotateAddonProfileAvailability(
564
721
  return results;
565
722
  }
566
723
 
567
- function readAddonProfiles(composePath: string): AddonProfile[] {
568
- if (!existsSync(composePath)) return [];
569
-
724
+ function readAddonProfilesFromContent(composeContent: string, composePath: string): AddonProfile[] {
570
725
  let parsed: unknown;
571
726
  try {
572
- parsed = parseYaml(readFileSync(composePath, "utf-8"));
727
+ parsed = parseYaml(composeContent);
573
728
  } catch (error) {
574
729
  logger.warn("failed to parse addon compose profiles", {
575
730
  composePath,
@@ -616,6 +771,11 @@ function readAddonProfiles(composePath: string): AddonProfile[] {
616
771
  return [...byProfile.values()];
617
772
  }
618
773
 
774
+ function readAddonProfiles(composePath: string): AddonProfile[] {
775
+ if (!existsSync(composePath)) return [];
776
+ return readAddonProfilesFromContent(readFileSync(composePath, "utf-8"), composePath);
777
+ }
778
+
619
779
  function readServiceLabels(raw: unknown): Record<string, string> {
620
780
  if (!raw) return {};
621
781
  const out: Record<string, string> = {};
@@ -639,12 +799,26 @@ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[]
639
799
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
640
800
 
641
801
  const composeCandidates = [
642
- join(homeDir, "config", "stack", "addons", name, "compose.yml"),
643
- join(homeDir, "state", "registry", "addons", name, "compose.yml"),
802
+ join(homeDir, "config", "stack", "channels.compose.yml"),
803
+ join(homeDir, "config", "stack", "services.compose.yml"),
804
+ join(homeDir, "config", "stack", "custom.compose.yml"),
644
805
  ];
645
806
 
807
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
808
+ if (localOpenpalmDir) {
809
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'channels.compose.yml'));
810
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'services.compose.yml'));
811
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'custom.compose.yml'));
812
+ }
813
+
646
814
  for (const composePath of composeCandidates) {
647
- const profiles = readAddonProfiles(composePath);
815
+ const profiles = readAddonProfiles(composePath).filter((profile) => profile.id.startsWith(`addon.${name}`));
816
+ if (profiles.length > 0) return profiles;
817
+ }
818
+
819
+ for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
820
+ const profiles = readAddonProfilesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`)
821
+ .filter((profile) => profile.id.startsWith(`addon.${name}`));
648
822
  if (profiles.length > 0) return profiles;
649
823
  }
650
824
 
@@ -659,42 +833,45 @@ function profileEnvKey(name: string): string {
659
833
  export function getAddonProfileSelection(stackDir: string, name: string): string | null {
660
834
  const env = readStackEnv(stackDir);
661
835
  const value = env[profileEnvKey(name)];
662
- return value && value.trim() ? value.trim() : null;
836
+ const normalized = value ? canonicalAddonProfileSelection(name, value) : '';
837
+ return normalized ? normalized : null;
663
838
  }
664
839
 
665
- export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
666
- const trimmed = profile.trim();
667
- if (!trimmed) throw new Error('Profile id cannot be empty');
840
+ export function setAddonProfileSelection(stackDir: string, name: string, profile: string, state?: ControlPlaneState): void {
841
+ const trimmed = canonicalAddonProfileSelection(name, profile);
842
+ if (!trimmed) throw new Error(`Invalid canonical profile id for addon ${name}: ${profile}`);
668
843
  patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
669
844
  }
670
845
 
671
- function enableAddon(homeDir: string, name: string): MutationResult {
846
+ function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
672
847
  try {
673
- copyAddonFromRegistry(homeDir, name);
674
- // Pre-create the addon services directory so Docker doesn't create it as root
675
- mkdirSync(join(homeDir, 'services', name), { recursive: true });
848
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
849
+ setStackSpecAddon(stackDir, name, true);
850
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
676
851
  return { ok: true };
677
852
  } catch (error) {
678
853
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
679
854
  }
680
855
  }
681
856
 
682
- function disableAddonByName(homeDir: string, name: string): MutationResult {
857
+ function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
683
858
  try {
684
- removeEnabledAddon(homeDir, name);
859
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
860
+ setStackSpecAddon(stackDir, name, false);
861
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
685
862
  return { ok: true };
686
863
  } catch (error) {
687
864
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
688
865
  }
689
866
  }
690
867
 
691
- export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
868
+ export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean, state?: ControlPlaneState): AddonMutationResult {
692
869
  if (!VALID_NAME_RE.test(name)) {
693
870
  return { ok: false, error: `Invalid addon name: ${name}` };
694
871
  }
695
872
 
696
873
  if (!listAvailableAddonIds().includes(name)) {
697
- return { ok: false, error: `Addon "${name}" not found in registry` };
874
+ return { ok: false, error: `Addon "${name}" is not built in` };
698
875
  }
699
876
 
700
877
  const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
@@ -709,16 +886,18 @@ export function setAddonEnabled(homeDir: string, stackDir: string, name: string,
709
886
  };
710
887
  }
711
888
 
712
- const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
889
+ const mutation = enabled ? enableAddon(homeDir, stackDir, name) : disableAddonByName(homeDir, stackDir, name);
713
890
  if (!mutation.ok) return mutation;
714
891
 
715
892
  if (enabled) {
716
- const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
717
- if (isChannelAddon(composePath)) {
718
- writeChannelSecrets(stackDir, { [name]: randomHex(16) });
893
+ if (['api', 'chat', 'discord', 'slack'].includes(name)) {
894
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
895
+ ensureChannelSecret(stackDir, channel);
896
+ }
719
897
  }
720
898
  }
721
899
 
900
+
722
901
  return {
723
902
  ok: true,
724
903
  enabled,
@@ -732,20 +911,20 @@ export function installAutomationFromRegistry(name: string, stashDir: string): M
732
911
  return { ok: false, error: `Invalid automation name: ${name}` };
733
912
  }
734
913
 
735
- const markdownContent = getRegistryAutomation(name);
736
- if (!markdownContent) {
914
+ const taskContent = getRegistryAutomation(name);
915
+ if (!taskContent) {
737
916
  return { ok: false, error: `Automation "${name}" not found in registry` };
738
917
  }
739
918
 
740
919
  const tasksDir = join(stashDir, 'tasks');
741
920
  mkdirSync(tasksDir, { recursive: true });
742
921
 
743
- const mdPath = join(tasksDir, `${name}.md`);
744
- if (existsSync(mdPath)) {
922
+ const ymlPath = join(tasksDir, `${name}.yml`);
923
+ if (existsSync(ymlPath)) {
745
924
  return { ok: false, error: `Automation "${name}" is already installed` };
746
925
  }
747
926
 
748
- writeFileSync(mdPath, markdownContent);
927
+ writeFileSync(ymlPath, taskContent);
749
928
  // The assistant container's 60-second akm tasks sync loop picks up the new
750
929
  // file from the shared stash mount and registers it with OS cron.
751
930
  return { ok: true };
@@ -756,12 +935,12 @@ export function uninstallAutomation(name: string, stashDir: string): MutationRes
756
935
  return { ok: false, error: `Invalid automation name: ${name}` };
757
936
  }
758
937
 
759
- const mdPath = join(stashDir, 'tasks', `${name}.md`);
760
- if (!existsSync(mdPath)) {
938
+ const ymlPath = join(stashDir, 'tasks', `${name}.yml`);
939
+ if (!existsSync(ymlPath)) {
761
940
  return { ok: false, error: `Automation "${name}" is not installed` };
762
941
  }
763
942
 
764
- rmSync(mdPath, { force: true });
943
+ rmSync(ymlPath, { force: true });
765
944
  // The assistant container's 60-second akm tasks sync will notice the file
766
945
  // is gone and deregister it from OS cron on next sync.
767
946
  return { ok: true };