@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -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 as OP_ENABLED_ADDONS in stack.env and
5
+ * resolved to Compose profiles. The fixed compose files under config/stack are
6
+ * the runtime source 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 { parseEnabledAddons } from './env.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 env = readStackEnv(join(homeDir, 'config', 'stack'));
434
+ const enabled = new Set(parseEnabledAddons(env.OP_ENABLED_ADDONS));
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,14 +574,18 @@ 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:-latest-<variant>}
578
+ *
579
+ * Voice images are published OUT OF BAND (publish-voice.yml), decoupled from the
580
+ * platform OP_IMAGE_TAG — they are heavy and rarely change. So the default is
581
+ * the moving `latest-<variant>` voice tag; operators pin a specific build by
582
+ * setting OP_VOICE_IMAGE_TAG (e.g. `v1.0.0-cpu`).
422
583
  */
423
584
  function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
424
585
  const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
425
586
  const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
426
587
  if (explicit) return `${namespace}/voice:${explicit}`;
427
- const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'v0.11.0';
428
- return `${namespace}/voice:${baseTag}-${variant}`;
588
+ return `${namespace}/voice:latest-${variant}`;
429
589
  }
430
590
 
431
591
  /**
@@ -525,11 +685,12 @@ export async function getAddonProfileAvailability(
525
685
 
526
686
  let result: AddonProfileAvailability;
527
687
  try {
528
- if (profile.id === 'cpu') {
688
+ const variant = resolveHardwareProfileVariant(profile.id);
689
+ if (variant === 'cpu') {
529
690
  result = { available: true };
530
- } else if (profile.id === 'cuda') {
691
+ } else if (variant === 'cuda') {
531
692
  result = await probeCuda();
532
- } else if (profile.id === 'rocm') {
693
+ } else if (variant === 'rocm') {
533
694
  result = await probeRocm();
534
695
  } else {
535
696
  // Unknown profile id — assume available; caller is responsible for
@@ -564,12 +725,10 @@ export async function annotateAddonProfileAvailability(
564
725
  return results;
565
726
  }
566
727
 
567
- function readAddonProfiles(composePath: string): AddonProfile[] {
568
- if (!existsSync(composePath)) return [];
569
-
728
+ function readAddonProfilesFromContent(composeContent: string, composePath: string): AddonProfile[] {
570
729
  let parsed: unknown;
571
730
  try {
572
- parsed = parseYaml(readFileSync(composePath, "utf-8"));
731
+ parsed = parseYaml(composeContent);
573
732
  } catch (error) {
574
733
  logger.warn("failed to parse addon compose profiles", {
575
734
  composePath,
@@ -616,6 +775,11 @@ function readAddonProfiles(composePath: string): AddonProfile[] {
616
775
  return [...byProfile.values()];
617
776
  }
618
777
 
778
+ function readAddonProfiles(composePath: string): AddonProfile[] {
779
+ if (!existsSync(composePath)) return [];
780
+ return readAddonProfilesFromContent(readFileSync(composePath, "utf-8"), composePath);
781
+ }
782
+
619
783
  function readServiceLabels(raw: unknown): Record<string, string> {
620
784
  if (!raw) return {};
621
785
  const out: Record<string, string> = {};
@@ -639,12 +803,26 @@ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[]
639
803
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
640
804
 
641
805
  const composeCandidates = [
642
- join(homeDir, "config", "stack", "addons", name, "compose.yml"),
643
- join(homeDir, "state", "registry", "addons", name, "compose.yml"),
806
+ join(homeDir, "config", "stack", "channels.compose.yml"),
807
+ join(homeDir, "config", "stack", "services.compose.yml"),
808
+ join(homeDir, "config", "stack", "custom.compose.yml"),
644
809
  ];
645
810
 
811
+ const localOpenpalmDir = resolveLocalOpenpalmDir();
812
+ if (localOpenpalmDir) {
813
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'channels.compose.yml'));
814
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'services.compose.yml'));
815
+ composeCandidates.push(join(localOpenpalmDir, 'config', 'stack', 'custom.compose.yml'));
816
+ }
817
+
646
818
  for (const composePath of composeCandidates) {
647
- const profiles = readAddonProfiles(composePath);
819
+ const profiles = readAddonProfiles(composePath).filter((profile) => profile.id.startsWith(`addon.${name}`));
820
+ if (profiles.length > 0) return profiles;
821
+ }
822
+
823
+ for (const assetName of ["channels.compose.yml", "services.compose.yml", "custom.compose.yml"]) {
824
+ const profiles = readAddonProfilesFromContent(readBundledStackAsset(assetName), `bundled:${assetName}`)
825
+ .filter((profile) => profile.id.startsWith(`addon.${name}`));
648
826
  if (profiles.length > 0) return profiles;
649
827
  }
650
828
 
@@ -659,42 +837,53 @@ function profileEnvKey(name: string): string {
659
837
  export function getAddonProfileSelection(stackDir: string, name: string): string | null {
660
838
  const env = readStackEnv(stackDir);
661
839
  const value = env[profileEnvKey(name)];
662
- return value && value.trim() ? value.trim() : null;
840
+ const normalized = value ? canonicalAddonProfileSelection(name, value) : '';
841
+ return normalized ? normalized : null;
663
842
  }
664
843
 
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');
844
+ export function setAddonProfileSelection(stackDir: string, name: string, profile: string, state?: ControlPlaneState): void {
845
+ const trimmed = canonicalAddonProfileSelection(name, profile);
846
+ if (!trimmed) throw new Error(`Invalid canonical profile id for addon ${name}: ${profile}`);
668
847
  patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
669
848
  }
670
849
 
671
- function enableAddon(homeDir: string, name: string): MutationResult {
850
+ /** Add/remove an addon id in the OP_ENABLED_ADDONS list in stack.env. */
851
+ function setEnabledAddonState(stackDir: string, name: string, enabled: boolean): void {
852
+ const current = new Set(parseEnabledAddons(readStackEnv(stackDir).OP_ENABLED_ADDONS));
853
+ if (enabled) current.add(name);
854
+ else current.delete(name);
855
+ patchSecretsEnvFile(stackDir, { OP_ENABLED_ADDONS: [...current].sort().join(',') });
856
+ }
857
+
858
+ function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
672
859
  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 });
860
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
861
+ setEnabledAddonState(stackDir, name, true);
862
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
676
863
  return { ok: true };
677
864
  } catch (error) {
678
865
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
679
866
  }
680
867
  }
681
868
 
682
- function disableAddonByName(homeDir: string, name: string): MutationResult {
869
+ function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
683
870
  try {
684
- removeEnabledAddon(homeDir, name);
871
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
872
+ setEnabledAddonState(stackDir, name, false);
873
+ if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
685
874
  return { ok: true };
686
875
  } catch (error) {
687
876
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
688
877
  }
689
878
  }
690
879
 
691
- export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
880
+ export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean, state?: ControlPlaneState): AddonMutationResult {
692
881
  if (!VALID_NAME_RE.test(name)) {
693
882
  return { ok: false, error: `Invalid addon name: ${name}` };
694
883
  }
695
884
 
696
885
  if (!listAvailableAddonIds().includes(name)) {
697
- return { ok: false, error: `Addon "${name}" not found in registry` };
886
+ return { ok: false, error: `Addon "${name}" is not built in` };
698
887
  }
699
888
 
700
889
  const wasEnabled = listEnabledAddonIds(homeDir).includes(name);
@@ -709,16 +898,18 @@ export function setAddonEnabled(homeDir: string, stackDir: string, name: string,
709
898
  };
710
899
  }
711
900
 
712
- const mutation = enabled ? enableAddon(homeDir, name) : disableAddonByName(homeDir, name);
901
+ const mutation = enabled ? enableAddon(homeDir, stackDir, name) : disableAddonByName(homeDir, stackDir, name);
713
902
  if (!mutation.ok) return mutation;
714
903
 
715
904
  if (enabled) {
716
- const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
717
- if (isChannelAddon(composePath)) {
718
- writeChannelSecrets(stackDir, { [name]: randomHex(16) });
905
+ if (['api', 'chat', 'discord', 'slack'].includes(name)) {
906
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
907
+ ensureChannelSecret(stackDir, channel);
908
+ }
719
909
  }
720
910
  }
721
911
 
912
+
722
913
  return {
723
914
  ok: true,
724
915
  enabled,
@@ -732,20 +923,20 @@ export function installAutomationFromRegistry(name: string, stashDir: string): M
732
923
  return { ok: false, error: `Invalid automation name: ${name}` };
733
924
  }
734
925
 
735
- const markdownContent = getRegistryAutomation(name);
736
- if (!markdownContent) {
926
+ const taskContent = getRegistryAutomation(name);
927
+ if (!taskContent) {
737
928
  return { ok: false, error: `Automation "${name}" not found in registry` };
738
929
  }
739
930
 
740
931
  const tasksDir = join(stashDir, 'tasks');
741
932
  mkdirSync(tasksDir, { recursive: true });
742
933
 
743
- const mdPath = join(tasksDir, `${name}.md`);
744
- if (existsSync(mdPath)) {
934
+ const ymlPath = join(tasksDir, `${name}.yml`);
935
+ if (existsSync(ymlPath)) {
745
936
  return { ok: false, error: `Automation "${name}" is already installed` };
746
937
  }
747
938
 
748
- writeFileSync(mdPath, markdownContent);
939
+ writeFileSync(ymlPath, taskContent);
749
940
  // The assistant container's 60-second akm tasks sync loop picks up the new
750
941
  // file from the shared stash mount and registers it with OS cron.
751
942
  return { ok: true };
@@ -756,12 +947,12 @@ export function uninstallAutomation(name: string, stashDir: string): MutationRes
756
947
  return { ok: false, error: `Invalid automation name: ${name}` };
757
948
  }
758
949
 
759
- const mdPath = join(stashDir, 'tasks', `${name}.md`);
760
- if (!existsSync(mdPath)) {
950
+ const ymlPath = join(stashDir, 'tasks', `${name}.yml`);
951
+ if (!existsSync(ymlPath)) {
761
952
  return { ok: false, error: `Automation "${name}" is not installed` };
762
953
  }
763
954
 
764
- rmSync(mdPath, { force: true });
955
+ rmSync(ymlPath, { force: true });
765
956
  // The assistant container's 60-second akm tasks sync will notice the file
766
957
  // is gone and deregister it from OS cron on next sync.
767
958
  return { ok: true };