@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.10

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 (49) hide show
  1. package/README.md +2 -0
  2. package/package.json +5 -1
  3. package/src/control-plane/akm-vault.test.ts +1 -4
  4. package/src/control-plane/akm-vault.ts +5 -1
  5. package/src/control-plane/channels.ts +8 -6
  6. package/src/control-plane/compose-args.test.ts +0 -12
  7. package/src/control-plane/compose-args.ts +0 -4
  8. package/src/control-plane/compose-errors.test.ts +106 -0
  9. package/src/control-plane/compose-errors.ts +117 -0
  10. package/src/control-plane/config-persistence.ts +49 -13
  11. package/src/control-plane/core-assets.ts +63 -7
  12. package/src/control-plane/docker.ts +15 -4
  13. package/src/control-plane/env.ts +4 -1
  14. package/src/control-plane/host-opencode.test.ts +0 -3
  15. package/src/control-plane/install-edge-cases.test.ts +29 -69
  16. package/src/control-plane/lifecycle.ts +39 -50
  17. package/src/control-plane/migrate-0110.test.ts +177 -0
  18. package/src/control-plane/migrate-0110.ts +99 -0
  19. package/src/control-plane/operator-ids.test.ts +130 -0
  20. package/src/control-plane/operator-ids.ts +89 -0
  21. package/src/control-plane/paths.ts +8 -3
  22. package/src/control-plane/registry-components.test.ts +3 -2
  23. package/src/control-plane/registry.test.ts +198 -4
  24. package/src/control-plane/registry.ts +333 -4
  25. package/src/control-plane/secret-mappings.ts +2 -3
  26. package/src/control-plane/secrets.ts +17 -11
  27. package/src/control-plane/setup-config.schema.json +3 -3
  28. package/src/control-plane/setup-status.ts +6 -1
  29. package/src/control-plane/setup-validation.ts +2 -2
  30. package/src/control-plane/setup.test.ts +24 -20
  31. package/src/control-plane/setup.ts +25 -41
  32. package/src/control-plane/spec-to-env.test.ts +30 -16
  33. package/src/control-plane/spec-to-env.ts +37 -21
  34. package/src/control-plane/stack-spec.test.ts +5 -11
  35. package/src/control-plane/stack-spec.ts +2 -6
  36. package/src/control-plane/types.ts +0 -22
  37. package/src/control-plane/ui-assets.ts +45 -9
  38. package/src/control-plane/validate.ts +1 -1
  39. package/src/index.ts +26 -13
  40. package/src/logger.test.ts +12 -12
  41. package/src/logger.ts +1 -1
  42. package/src/control-plane/admin-token.ts +0 -73
  43. package/src/control-plane/audit.ts +0 -41
  44. package/src/control-plane/lock.test.ts +0 -194
  45. package/src/control-plane/lock.ts +0 -176
  46. package/src/control-plane/provider-config.ts +0 -34
  47. package/src/control-plane/secret-backend.test.ts +0 -349
  48. package/src/control-plane/secret-backend.ts +0 -362
  49. package/src/control-plane/spec-validator.ts +0 -62
@@ -0,0 +1,130 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
6
+
7
+ let tempDir = "";
8
+
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), "openpalm-opids-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tempDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("resolveOperatorIds", () => {
18
+ test("returns the homeDir's owner when it exists and is non-root", () => {
19
+ // A mkdtemp directory is owned by the current process — neither
20
+ // root (in any reasonable test env) nor a hard-coded 1000.
21
+ const expected = statSync(tempDir);
22
+ const ids = resolveOperatorIds(tempDir);
23
+ if (process.platform === "win32") {
24
+ expect(ids).toBeNull();
25
+ return;
26
+ }
27
+ expect(ids).not.toBeNull();
28
+ expect(ids!.uid).toBe(expected.uid);
29
+ expect(ids!.gid).toBe(expected.gid);
30
+ });
31
+
32
+ test("falls back to process UID when homeDir does not exist", () => {
33
+ const missing = join(tempDir, "does-not-exist");
34
+ const ids = resolveOperatorIds(missing);
35
+ if (process.platform === "win32") {
36
+ expect(ids).toBeNull();
37
+ return;
38
+ }
39
+ expect(ids).not.toBeNull();
40
+ // process.getuid is guaranteed on POSIX runtimes used by this test
41
+ expect(ids!.uid).toBe(process.getuid!());
42
+ expect(ids!.gid).toBe(process.getgid!());
43
+ });
44
+
45
+ test("never returns 0 (root) — falls back to process UID when homeDir is root-owned", () => {
46
+ // We can't easily chown a dir to root without root. Instead, exercise
47
+ // the branch via a faked statSync output: build a path that triggers
48
+ // the "owner is 0, prefer process UID" code path by ensuring real
49
+ // tempDir owner is the process UID and asserting the result for a
50
+ // missing path matches process UID (already covered above). The
51
+ // explicit 0-check is enforced by the implementation; this test
52
+ // documents that the function never *returns* 0 for any of the
53
+ // exercised inputs in a non-root test process.
54
+ const ids = resolveOperatorIds(tempDir);
55
+ if (process.platform === "win32") {
56
+ expect(ids).toBeNull();
57
+ return;
58
+ }
59
+ expect(ids).not.toBeNull();
60
+ expect(ids!.uid).toBeGreaterThan(0);
61
+ expect(ids!.gid).toBeGreaterThan(0);
62
+ });
63
+
64
+ test("returns null when BOTH homeDir owner and process UID/GID are 0 (root install on root-owned OP_HOME)", () => {
65
+ if (process.platform === "win32") {
66
+ // win32 short-circuits before any of this logic
67
+ expect(resolveOperatorIds(tempDir)).toBeNull();
68
+ return;
69
+ }
70
+
71
+ // Stub process.getuid / getgid to simulate running as root. On Linux,
72
+ // `/` is owned by uid=0 gid=0, so passing "/" gives us a root-owned
73
+ // homeDir. Combined with the stubbed process IDs, this hits the
74
+ // "both signals are root" branch that previously returned {0,0}.
75
+ const origGetuid = process.getuid;
76
+ const origGetgid = process.getgid;
77
+ try {
78
+ (process as unknown as { getuid: () => number }).getuid = () => 0;
79
+ (process as unknown as { getgid: () => number }).getgid = () => 0;
80
+ // Sanity-check the assumption that "/" is root-owned in this env
81
+ // before relying on it as a fixture. On macOS / Linux CI runners
82
+ // this holds; if a future weird env breaks it, the assertion
83
+ // surfaces clearly rather than producing a confusing pass.
84
+ const rootStat = statSync("/");
85
+ expect(rootStat.uid).toBe(0);
86
+ expect(rootStat.gid).toBe(0);
87
+
88
+ const ids = resolveOperatorIds("/");
89
+ expect(ids).toBeNull();
90
+ } finally {
91
+ (process as unknown as { getuid: typeof origGetuid }).getuid = origGetuid;
92
+ (process as unknown as { getgid: typeof origGetgid }).getgid = origGetgid;
93
+ }
94
+ });
95
+
96
+ test("returns null on win32", () => {
97
+ // This test is informational; on non-win32 it doesn't run the win32
98
+ // branch. The check is left here for documentation and runs as a
99
+ // no-op assertion on POSIX.
100
+ if (process.platform === "win32") {
101
+ expect(resolveOperatorIds(tempDir)).toBeNull();
102
+ } else {
103
+ // No-op: confirms the test compiles and the helper is callable.
104
+ expect(typeof resolveOperatorIds).toBe("function");
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("hasUsableOperatorId", () => {
110
+ test("returns true for positive numeric values", () => {
111
+ expect(hasUsableOperatorId({ OP_UID: "1000" }, "OP_UID")).toBe(true);
112
+ expect(hasUsableOperatorId({ OP_GID: "501" }, "OP_GID")).toBe(true);
113
+ });
114
+
115
+ test("returns false for missing key", () => {
116
+ expect(hasUsableOperatorId({}, "OP_UID")).toBe(false);
117
+ });
118
+
119
+ test("returns false for empty string", () => {
120
+ expect(hasUsableOperatorId({ OP_UID: "" }, "OP_UID")).toBe(false);
121
+ });
122
+
123
+ test("returns false for zero", () => {
124
+ expect(hasUsableOperatorId({ OP_UID: "0" }, "OP_UID")).toBe(false);
125
+ });
126
+
127
+ test("returns false for non-numeric garbage", () => {
128
+ expect(hasUsableOperatorId({ OP_UID: "abc" }, "OP_UID")).toBe(false);
129
+ });
130
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Operator UID/GID detection for stack.env.
3
+ *
4
+ * Container processes that bind-mount host paths (voice models, addon
5
+ * caches, etc.) run as `${OP_UID}:${OP_GID}`. If those values are wrong,
6
+ * the container can't write to the mounted volume and the install
7
+ * silently degrades (model downloads stall, healthchecks time out).
8
+ *
9
+ * Detection strategy (Linux/macOS):
10
+ * 1. Stat OP_HOME. If it exists and is owned by a non-root user,
11
+ * prefer that owner — operator may have created OP_HOME under a
12
+ * different account than the one running install (e.g. sudo
13
+ * install for a service user).
14
+ * 2. Otherwise fall back to the process's real UID/GID.
15
+ * 3. Never return 0 (root). Running install as root is allowed but
16
+ * the container must run as the operator, not root.
17
+ *
18
+ * Returns `null` on Windows (containers run in WSL2's Linux; OP_UID
19
+ * has no meaning on the win32 host process itself).
20
+ */
21
+ import { statSync } from "node:fs";
22
+
23
+ export type OperatorIds = { uid: number; gid: number };
24
+
25
+ /**
26
+ * Resolve the operator's UID/GID for stack.env.
27
+ * Returns null on Windows or when neither homeDir owner nor process
28
+ * UID/GID is available (e.g. process.getuid undefined on some runtimes).
29
+ */
30
+ export function resolveOperatorIds(homeDir: string): OperatorIds | null {
31
+ if (process.platform === "win32") return null;
32
+
33
+ const processUid = typeof process.getuid === "function" ? process.getuid() : undefined;
34
+ const processGid = typeof process.getgid === "function" ? process.getgid() : undefined;
35
+
36
+ let ownerUid: number | undefined;
37
+ let ownerGid: number | undefined;
38
+ try {
39
+ const st = statSync(homeDir);
40
+ ownerUid = st.uid;
41
+ ownerGid = st.gid;
42
+ } catch {
43
+ // homeDir may not exist yet during a first-time install — that's fine,
44
+ // we fall through to the process IDs below.
45
+ }
46
+
47
+ // Prefer the homeDir owner when it's a non-root user (the operator may
48
+ // have created OP_HOME under a different account than the one running
49
+ // install — e.g. an admin running `sudo openpalm install` on behalf of
50
+ // a service account).
51
+ const uid =
52
+ ownerUid !== undefined && ownerUid !== 0
53
+ ? ownerUid
54
+ : processUid !== undefined && processUid !== 0
55
+ ? processUid
56
+ : ownerUid; // last resort: homeDir owner even if 0, or undefined
57
+
58
+ const gid =
59
+ ownerGid !== undefined && ownerGid !== 0
60
+ ? ownerGid
61
+ : processGid !== undefined && processGid !== 0
62
+ ? processGid
63
+ : ownerGid;
64
+
65
+ if (uid === undefined || gid === undefined) return null;
66
+
67
+ // Final guard: never return 0 (root). This happens when BOTH the OP_HOME
68
+ // owner AND the process UID are root (e.g. `sudo openpalm install` on a
69
+ // freshly-created root-owned OP_HOME, common in CI builds and Docker-based
70
+ // installer flows). Returning null causes the caller to skip writing
71
+ // OP_UID/OP_GID to stack.env, and compose's `${OP_UID:-1000}` default
72
+ // kicks in — container runs as 1000:1000, which is the sane fallback
73
+ // when no real operator can be detected.
74
+ if (uid === 0 || gid === 0) return null;
75
+
76
+ return { uid, gid };
77
+ }
78
+
79
+ /**
80
+ * Returns true if the parsed stack.env already has a usable
81
+ * (non-zero, numeric) operator ID for the given key.
82
+ * Operator may have hand-set OP_UID/OP_GID; respect that.
83
+ */
84
+ export function hasUsableOperatorId(parsed: Record<string, string>, key: "OP_UID" | "OP_GID"): boolean {
85
+ const raw = parsed[key];
86
+ if (!raw) return false;
87
+ const n = Number(raw);
88
+ return Number.isFinite(n) && n > 0;
89
+ }
@@ -31,8 +31,6 @@ export const assistantConfigDir = (s: ControlPlaneState): string => `${s.conf
31
31
  export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
32
32
  /** Guardian HMAC channel secrets */
33
33
  export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
34
- /** Stack spec: capability assignments */
35
- export const stackSpecFilePath = (s: ControlPlaneState): string => `${s.stackDir}/stack.yml`;
36
34
 
37
35
  // ── Cache directory — regenerable/semi-persistent ───────────────────────────
38
36
 
@@ -52,8 +50,15 @@ export const akmStateDir = (s: ControlPlaneState): string => `${s.stat
52
50
  export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
53
51
  export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
54
52
  export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
55
- export const adminAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/admin-audit.jsonl`;
53
+ /**
54
+ * Guardian's own audit log of channel ingress (HMAC verify, replay, rate
55
+ * limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
56
+ * `admin-audit.jsonl` — OpenCode session logs are the audit trail for
57
+ * chat + tool activity.
58
+ */
56
59
  export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
60
+ /** One-shot 0.11.0 migration log (OP_UI_TOKEN → OPENCODE_SERVER_PASSWORD, endpoints.json move) */
61
+ export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
57
62
  export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
58
63
  export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
59
64
  export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
@@ -343,8 +343,9 @@ describe("registry component sensitive fields", () => {
343
343
 
344
344
  for (const id of fullAddonIds) {
345
345
  it(`${id}: has at least one @sensitive field (channel secret)`, () => {
346
- // ollama is a local inference server — no channel secret or API key needed
347
- if (id === "ollama") return;
346
+ // ollama and voice are local inference servers — no channel secret
347
+ // or upstream API key needed (LAN-only, no auth by design).
348
+ if (id === "ollama" || id === "voice") return;
348
349
  const schema = readComponentFile(id, ".env.schema");
349
350
  const entries = parseEnvSchema(schema);
350
351
  const sensitiveEntries = entries.filter((e) =>
@@ -21,11 +21,15 @@ import {
21
21
  getRegistryAddonConfig,
22
22
  listAvailableAddonIds,
23
23
  getAddonServiceNames,
24
- enableAddon,
25
- disableAddonByName,
24
+ getAddonProfiles,
25
+ getAddonProfileSelection,
26
+ setAddonProfileSelection,
26
27
  setAddonEnabled,
27
28
  installAutomationFromRegistry,
28
29
  uninstallAutomation,
30
+ getAddonProfileAvailability,
31
+ annotateAddonProfileAvailability,
32
+ __addonAvailabilityTestHooks,
29
33
  } from "./registry.js";
30
34
 
31
35
  // ── Validation Tests ─────────────────────────────────────────────────
@@ -306,10 +310,11 @@ describe("materialized registry catalog", () => {
306
310
 
307
311
  materializeRegistryCatalog(sourceRoot);
308
312
 
309
- expect(enableAddon(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
313
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
314
+ expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
310
315
  expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
311
316
 
312
- expect(disableAddonByName(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
317
+ expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
313
318
  expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
314
319
  });
315
320
 
@@ -391,6 +396,67 @@ describe("materialized registry catalog", () => {
391
396
  expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
392
397
  });
393
398
 
399
+ it("parses compose profiles + openpalm.profile.* labels per addon", () => {
400
+ const sourceRoot = join(tmpDir, 'repo');
401
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
402
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
403
+
404
+ mkdirSync(addonDir, { recursive: true });
405
+ mkdirSync(automationsDir, { recursive: true });
406
+ writeFileSync(
407
+ join(addonDir, 'compose.yml'),
408
+ [
409
+ 'services:',
410
+ ' voice:',
411
+ ' profiles: [cpu]',
412
+ ' image: openpalm/voice:cpu',
413
+ ' labels:',
414
+ ' openpalm.profile.label: CPU',
415
+ ' openpalm.profile.default: "true"',
416
+ ' voice-cuda:',
417
+ ' profiles: [cuda]',
418
+ ' image: openpalm/voice:cuda',
419
+ ' labels:',
420
+ ' openpalm.profile.label: NVIDIA',
421
+ ' openpalm.profile.requires: nvidia-container-toolkit',
422
+ '',
423
+ ].join('\n'),
424
+ );
425
+ writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
426
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
427
+
428
+ materializeRegistryCatalog(sourceRoot);
429
+
430
+ const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
431
+ expect(profiles).toEqual([
432
+ { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
433
+ { id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
434
+ ]);
435
+ });
436
+
437
+ it("round-trips addon profile selection through stack.env", () => {
438
+ const sourceRoot = join(tmpDir, 'repo');
439
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
440
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
441
+
442
+ mkdirSync(addonDir, { recursive: true });
443
+ mkdirSync(automationsDir, { recursive: true });
444
+ writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
445
+ writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
446
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
447
+
448
+ materializeRegistryCatalog(sourceRoot);
449
+
450
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
451
+ mkdirSync(stackDir, { recursive: true });
452
+ writeFileSync(join(stackDir, 'stack.env'), '');
453
+
454
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
455
+ setAddonProfileSelection(stackDir, 'voice', 'cuda');
456
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
457
+ expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
458
+ });
459
+
394
460
  it("installs and uninstalls automations through stash/tasks", () => {
395
461
  const sourceRoot = join(tmpDir, 'repo');
396
462
  const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
@@ -414,3 +480,131 @@ describe("materialized registry catalog", () => {
414
480
  expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
415
481
  });
416
482
  });
483
+
484
+ // ── Host capability probes ───────────────────────────────────────────
485
+
486
+ describe("getAddonProfileAvailability", () => {
487
+ beforeEach(() => {
488
+ __addonAvailabilityTestHooks.reset();
489
+ });
490
+
491
+ afterEach(() => {
492
+ __addonAvailabilityTestHooks.reset();
493
+ });
494
+
495
+ it("returns available:true for the cpu profile (no host requirements)", async () => {
496
+ const result = await getAddonProfileAvailability({ id: 'cpu' });
497
+ expect(result.available).toBe(true);
498
+ expect(result.reason).toBeUndefined();
499
+ });
500
+
501
+ it("returns available:true for unknown profile ids (no host-side gating)", async () => {
502
+ const result = await getAddonProfileAvailability({ id: 'something-else' });
503
+ expect(result.available).toBe(true);
504
+ });
505
+
506
+ it("caches the result across calls (probe runs only once)", async () => {
507
+ const a = await getAddonProfileAvailability({ id: 'cpu' });
508
+ const b = await getAddonProfileAvailability({ id: 'cpu' });
509
+ expect(a).toBe(b); // same reference — cached
510
+ });
511
+
512
+ it("probes cuda: returns available:false on a host with no NVIDIA runtime / CDI", async () => {
513
+ // This test runs on CI/dev machines without GPUs. We don't mock execFile;
514
+ // we just assert the contract: when neither signal is present, the
515
+ // reason mentions nvidia-container-toolkit. If a future GPU host runs
516
+ // this test, the assertion still tolerates the success case.
517
+ const result = await getAddonProfileAvailability({ id: 'cuda' });
518
+ if (!result.available) {
519
+ expect(result.reason).toContain('NVIDIA');
520
+ } else {
521
+ // Host genuinely has the runtime registered — accept it.
522
+ expect(result.reason).toBeUndefined();
523
+ }
524
+ });
525
+
526
+ it("probes rocm: returns available:false when /dev/kfd is missing", async () => {
527
+ const result = await getAddonProfileAvailability({ id: 'rocm' });
528
+ if (!result.available) {
529
+ expect(result.reason).toContain('ROCm');
530
+ } else {
531
+ expect(result.reason).toBeUndefined();
532
+ }
533
+ });
534
+
535
+ it("probes rocm: when devices exist, reports unpublished image distinctly from missing-device case", async () => {
536
+ // On a host without /dev/kfd, we hit the device-missing branch and
537
+ // get the "devices not present" copy. On a ROCm host, we'd fall
538
+ // through to the manifest-inspect probe and (until 0.11.0-rocm6
539
+ // ships) get the "image not published yet" copy. Both must mention
540
+ // ROCm so operator-facing copy stays consistent.
541
+ const result = await getAddonProfileAvailability({ id: 'rocm' });
542
+ if (!result.available && existsSync('/dev/kfd') && existsSync('/dev/dri')) {
543
+ expect(result.reason).toMatch(/image not published|CPU profile/i);
544
+ }
545
+ if (!result.available && !(existsSync('/dev/kfd') && existsSync('/dev/dri'))) {
546
+ expect(result.reason).toMatch(/devices not present/i);
547
+ }
548
+ });
549
+ });
550
+
551
+ describe("execFileNoThrow (ENOENT capture)", () => {
552
+ it("captures ENOENT for a missing binary as 'spawn <cmd> ENOENT' stderr", async () => {
553
+ const result = await __addonAvailabilityTestHooks.execFileNoThrow(
554
+ '/nonexistent/path/to/openpalm-test-no-such-binary-zzz',
555
+ ['--help'],
556
+ 2_000,
557
+ );
558
+ expect(result.ok).toBe(false);
559
+ expect(result.stderr).toMatch(/ENOENT/);
560
+ // When the binary is "docker", the synthetic stderr becomes
561
+ // `spawn docker ENOENT: command not found` — that string matches the
562
+ // translateDockerError regex `/spawn .*docker.*ENOENT/i` so the
563
+ // operator gets actionable copy instead of "unknown error (no stderr)".
564
+ expect(result.stderr).toMatch(/spawn\s+\S*\s*ENOENT/);
565
+ });
566
+
567
+ it("formats ENOENT for `docker` so translateDockerError can match it", async () => {
568
+ // Use an absolute path that we know doesn't exist so the test is
569
+ // deterministic regardless of whether docker is installed on the host.
570
+ const result = await __addonAvailabilityTestHooks.execFileNoThrow(
571
+ 'docker-not-installed-zzz',
572
+ ['info'],
573
+ 2_000,
574
+ );
575
+ expect(result.ok).toBe(false);
576
+ expect(result.stderr).toBe('spawn docker-not-installed-zzz ENOENT: command not found');
577
+ });
578
+ });
579
+
580
+ describe("annotateAddonProfileAvailability", () => {
581
+ beforeEach(() => {
582
+ __addonAvailabilityTestHooks.reset();
583
+ });
584
+
585
+ afterEach(() => {
586
+ __addonAvailabilityTestHooks.reset();
587
+ });
588
+
589
+ it("decorates each profile with available + optional reason", async () => {
590
+ const out = await annotateAddonProfileAvailability([
591
+ { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
592
+ { id: 'rocm', services: ['voice-rocm'], label: 'AMD' },
593
+ ]);
594
+ expect(out).toHaveLength(2);
595
+ expect(out[0]?.id).toBe('cpu');
596
+ expect(out[0]?.available).toBe(true);
597
+ // Preserves original fields.
598
+ expect(out[0]?.label).toBe('CPU');
599
+ expect(out[0]?.default).toBe(true);
600
+ expect(out[1]?.id).toBe('rocm');
601
+ expect(typeof out[1]?.available).toBe('boolean');
602
+ });
603
+
604
+ it("does not mutate the input array", async () => {
605
+ const input = [{ id: 'cpu', services: ['voice'] }];
606
+ const before = JSON.parse(JSON.stringify(input));
607
+ await annotateAddonProfileAvailability(input);
608
+ expect(input).toEqual(before);
609
+ });
610
+ });