@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.
- package/README.md +2 -0
- package/package.json +5 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -12
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +49 -13
- package/src/control-plane/core-assets.ts +63 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/env.ts +4 -1
- package/src/control-plane/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +29 -69
- package/src/control-plane/lifecycle.ts +39 -50
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +8 -3
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +198 -4
- package/src/control-plane/registry.ts +333 -4
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +17 -11
- package/src/control-plane/setup-config.schema.json +3 -3
- package/src/control-plane/setup-status.ts +6 -1
- package/src/control-plane/setup-validation.ts +2 -2
- package/src/control-plane/setup.test.ts +24 -20
- package/src/control-plane/setup.ts +25 -41
- package/src/control-plane/spec-to-env.test.ts +30 -16
- package/src/control-plane/spec-to-env.ts +37 -21
- package/src/control-plane/stack-spec.test.ts +5 -11
- package/src/control-plane/stack-spec.ts +2 -6
- package/src/control-plane/types.ts +0 -22
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +26 -13
- package/src/logger.test.ts +12 -12
- package/src/logger.ts +1 -1
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/audit.ts +0 -41
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -349
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* Install seeds it once; refresh replaces it explicitly.
|
|
6
6
|
*/
|
|
7
7
|
import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { parse as parseYaml } from 'yaml';
|
|
12
12
|
import { createLogger } from '../logger.js';
|
|
13
13
|
import { isChannelAddon } from './channels.js';
|
|
14
14
|
import { randomHex, writeChannelSecrets } from './config-persistence.js';
|
|
15
|
+
import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
|
|
15
16
|
import {
|
|
16
17
|
resolveRegistryAddonsDir,
|
|
17
18
|
resolveRegistryAutomationsDir,
|
|
@@ -83,7 +84,7 @@ export type RegistryCatalogVerification = {
|
|
|
83
84
|
automationCount: number;
|
|
84
85
|
};
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
type MutationResult = { ok: true } | { ok: false; error: string };
|
|
87
88
|
export type AddonMutationResult = (
|
|
88
89
|
| { ok: true; enabled: boolean; changed: boolean; services: string[] }
|
|
89
90
|
| { ok: false; error: string }
|
|
@@ -339,7 +340,335 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
|
|
|
339
340
|
return [];
|
|
340
341
|
}
|
|
341
342
|
|
|
342
|
-
export
|
|
343
|
+
export type AddonProfile = {
|
|
344
|
+
id: string;
|
|
345
|
+
services: string[];
|
|
346
|
+
label?: string;
|
|
347
|
+
requires?: string;
|
|
348
|
+
default?: boolean;
|
|
349
|
+
/**
|
|
350
|
+
* Whether the host can run this profile.
|
|
351
|
+
*
|
|
352
|
+
* Populated by `getAddonProfileAvailability()`. When the value is missing
|
|
353
|
+
* (e.g. older catalogs), callers should treat the profile as available.
|
|
354
|
+
*/
|
|
355
|
+
available?: boolean;
|
|
356
|
+
/** Human-readable reason when `available === false`. */
|
|
357
|
+
reason?: string;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ── Host capability probes ─────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export type AddonProfileAvailability = { available: boolean; reason?: string };
|
|
363
|
+
|
|
364
|
+
const HOST_PROBE_TIMEOUT_MS = 2_000;
|
|
365
|
+
|
|
366
|
+
// Process-lifetime cache. Hardware presence does not change while the UI
|
|
367
|
+
// server is running, so probing once is enough.
|
|
368
|
+
const availabilityCache = new Map<string, AddonProfileAvailability>();
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Reset the host-capability cache. Test-only — not exported.
|
|
372
|
+
*/
|
|
373
|
+
function _resetAvailabilityCacheForTests(): void {
|
|
374
|
+
availabilityCache.clear();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Exported under a deliberately ugly name so test files can reach it.
|
|
378
|
+
export const __addonAvailabilityTestHooks = {
|
|
379
|
+
reset: _resetAvailabilityCacheForTests,
|
|
380
|
+
/**
|
|
381
|
+
* Test-only: exposes the internal exec wrapper so tests can verify
|
|
382
|
+
* ENOENT (missing binary) is surfaced as actionable stderr that the
|
|
383
|
+
* docker-error translator can recognise.
|
|
384
|
+
*/
|
|
385
|
+
execFileNoThrow: (cmd: string, args: string[], timeoutMs: number) =>
|
|
386
|
+
execFileNoThrow(cmd, args, timeoutMs),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
function execFileNoThrow(
|
|
390
|
+
cmd: string,
|
|
391
|
+
args: string[],
|
|
392
|
+
timeoutMs: number,
|
|
393
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
394
|
+
return new Promise((resolve) => {
|
|
395
|
+
execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
396
|
+
// ENOENT (binary missing) surfaces here with no stderr — child_process
|
|
397
|
+
// never gets to exec the program. Inject a synthetic stderr that
|
|
398
|
+
// matches the translateDockerError ENOENT regex so callers get
|
|
399
|
+
// actionable copy instead of "unknown error (no stderr)".
|
|
400
|
+
let mergedStderr = stderr?.toString() ?? '';
|
|
401
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
402
|
+
if (code && !mergedStderr) {
|
|
403
|
+
if (code === 'ENOENT') {
|
|
404
|
+
mergedStderr = `spawn ${cmd} ENOENT: command not found`;
|
|
405
|
+
} else {
|
|
406
|
+
mergedStderr = `spawn ${cmd} ${code}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
resolve({
|
|
410
|
+
ok: !error,
|
|
411
|
+
stdout: stdout?.toString() ?? '',
|
|
412
|
+
stderr: mergedStderr,
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Compute the openpalm/voice image ref for a given GPU variant, matching
|
|
420
|
+
* 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>}
|
|
422
|
+
*/
|
|
423
|
+
function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
|
|
424
|
+
const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
|
|
425
|
+
const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
|
|
426
|
+
if (explicit) return `${namespace}/voice:${explicit}`;
|
|
427
|
+
const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'v0.11.0';
|
|
428
|
+
return `${namespace}/voice:${baseTag}-${variant}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* `docker manifest inspect <ref>` returns 0 only when the registry can
|
|
433
|
+
* resolve a manifest for that ref. We use it as the cheap "is this image
|
|
434
|
+
* actually published?" check — no pull required. The retry handles
|
|
435
|
+
* transient registry hiccups. Timeout is short because the manifest blob
|
|
436
|
+
* is a few KB.
|
|
437
|
+
*/
|
|
438
|
+
async function dockerManifestExists(imageRef: string): Promise<boolean> {
|
|
439
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
440
|
+
const res = await execFileNoThrow(
|
|
441
|
+
'docker',
|
|
442
|
+
['manifest', 'inspect', imageRef],
|
|
443
|
+
5_000,
|
|
444
|
+
);
|
|
445
|
+
if (res.ok) return true;
|
|
446
|
+
// If docker itself is missing (ENOENT), retrying won't help.
|
|
447
|
+
if (/ENOENT/.test(res.stderr)) return false;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function probeCuda(): Promise<AddonProfileAvailability> {
|
|
453
|
+
// Two acceptance signals:
|
|
454
|
+
// 1. `docker info` reports an `nvidia` runtime (toolkit installed +
|
|
455
|
+
// `nvidia-ctk runtime configure --runtime=docker` was run).
|
|
456
|
+
// 2. `/etc/cdi/nvidia.yaml` exists (CDI-mode daemon with a generated
|
|
457
|
+
// spec). We don't require the runtime in this case — the route's
|
|
458
|
+
// CDI fallback can switch the compose to driver:cdi.
|
|
459
|
+
try {
|
|
460
|
+
if (existsSync('/etc/cdi/nvidia.yaml')) return { available: true };
|
|
461
|
+
} catch {
|
|
462
|
+
// existsSync only throws on path-syntax issues; ignore and probe docker.
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const result = await execFileNoThrow(
|
|
466
|
+
'docker',
|
|
467
|
+
['info', '--format', '{{json .Runtimes}}'],
|
|
468
|
+
HOST_PROBE_TIMEOUT_MS,
|
|
469
|
+
);
|
|
470
|
+
if (result.ok && result.stdout.includes('"nvidia"')) {
|
|
471
|
+
return { available: true };
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
available: false,
|
|
475
|
+
reason: 'NVIDIA runtime not registered. Install nvidia-container-toolkit or enable CDI.',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function probeRocm(): Promise<AddonProfileAvailability> {
|
|
480
|
+
// Hardware gate: ROCm needs both the KFD char device and the GPU DRI nodes.
|
|
481
|
+
let devicesPresent = false;
|
|
482
|
+
try {
|
|
483
|
+
devicesPresent = existsSync('/dev/kfd') && existsSync('/dev/dri');
|
|
484
|
+
} catch {
|
|
485
|
+
devicesPresent = false;
|
|
486
|
+
}
|
|
487
|
+
if (!devicesPresent) {
|
|
488
|
+
return {
|
|
489
|
+
available: false,
|
|
490
|
+
reason: 'AMD ROCm devices not present on this host.',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Image gate: the openpalm/voice:*-rocm6 image isn't published yet, so
|
|
495
|
+
// even on a fully-functional ROCm host the compose-up would fail with a
|
|
496
|
+
// manifest-unknown pull error. Refuse the profile until the image lands.
|
|
497
|
+
const imageRef = voiceImageRef('rocm6');
|
|
498
|
+
const published = await dockerManifestExists(imageRef);
|
|
499
|
+
if (!published) {
|
|
500
|
+
return {
|
|
501
|
+
available: false,
|
|
502
|
+
reason: 'AMD ROCm image not published yet. Check back in a future release or use the CPU profile.',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return { available: true };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Probe the host for the capabilities required by an addon profile.
|
|
510
|
+
*
|
|
511
|
+
* Results are cached for the lifetime of the process — hardware doesn't
|
|
512
|
+
* change while the UI server runs. All probes use execFile (no shell)
|
|
513
|
+
* and never throw: errors collapse to `{ available: false, reason }`.
|
|
514
|
+
*
|
|
515
|
+
* Unknown profile ids default to `available: true` so unrelated addons
|
|
516
|
+
* (e.g. a future "high-mem" profile that doesn't probe hardware) keep
|
|
517
|
+
* working without code changes here.
|
|
518
|
+
*/
|
|
519
|
+
export async function getAddonProfileAvailability(
|
|
520
|
+
profile: Pick<AddonProfile, 'id'>,
|
|
521
|
+
): Promise<AddonProfileAvailability> {
|
|
522
|
+
const cacheKey = profile.id;
|
|
523
|
+
const cached = availabilityCache.get(cacheKey);
|
|
524
|
+
if (cached) return cached;
|
|
525
|
+
|
|
526
|
+
let result: AddonProfileAvailability;
|
|
527
|
+
try {
|
|
528
|
+
if (profile.id === 'cpu') {
|
|
529
|
+
result = { available: true };
|
|
530
|
+
} else if (profile.id === 'cuda') {
|
|
531
|
+
result = await probeCuda();
|
|
532
|
+
} else if (profile.id === 'rocm') {
|
|
533
|
+
result = await probeRocm();
|
|
534
|
+
} else {
|
|
535
|
+
// Unknown profile id — assume available; caller is responsible for
|
|
536
|
+
// labelling profiles that need host capability gating.
|
|
537
|
+
result = { available: true };
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// Belt-and-braces: any unexpected throw collapses to unavailable.
|
|
541
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
542
|
+
result = { available: false, reason: `probe failed: ${reason}` };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
availabilityCache.set(cacheKey, result);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Decorate a list of profiles with `available`/`reason` based on the host
|
|
551
|
+
* capability probes. Returns a fresh array; does not mutate inputs.
|
|
552
|
+
*/
|
|
553
|
+
export async function annotateAddonProfileAvailability(
|
|
554
|
+
profiles: AddonProfile[],
|
|
555
|
+
): Promise<AddonProfile[]> {
|
|
556
|
+
const results = await Promise.all(
|
|
557
|
+
profiles.map(async (p) => {
|
|
558
|
+
const a = await getAddonProfileAvailability(p);
|
|
559
|
+
const annotated: AddonProfile = { ...p, available: a.available };
|
|
560
|
+
if (a.reason) annotated.reason = a.reason;
|
|
561
|
+
return annotated;
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
return results;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readAddonProfiles(composePath: string): AddonProfile[] {
|
|
568
|
+
if (!existsSync(composePath)) return [];
|
|
569
|
+
|
|
570
|
+
let parsed: unknown;
|
|
571
|
+
try {
|
|
572
|
+
parsed = parseYaml(readFileSync(composePath, "utf-8"));
|
|
573
|
+
} catch (error) {
|
|
574
|
+
logger.warn("failed to parse addon compose profiles", {
|
|
575
|
+
composePath,
|
|
576
|
+
error: error instanceof Error ? error.message : String(error),
|
|
577
|
+
});
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const services = parsed && typeof parsed === "object"
|
|
582
|
+
? (parsed as { services?: unknown }).services
|
|
583
|
+
: undefined;
|
|
584
|
+
if (!services || typeof services !== "object" || Array.isArray(services)) return [];
|
|
585
|
+
|
|
586
|
+
const byProfile = new Map<string, AddonProfile>();
|
|
587
|
+
for (const [svcName, svcRaw] of Object.entries(services as Record<string, unknown>)) {
|
|
588
|
+
if (!svcRaw || typeof svcRaw !== "object") continue;
|
|
589
|
+
const svc = svcRaw as { profiles?: unknown; labels?: unknown };
|
|
590
|
+
if (!Array.isArray(svc.profiles)) continue;
|
|
591
|
+
const profileIds = svc.profiles.filter((p): p is string => typeof p === "string");
|
|
592
|
+
if (profileIds.length === 0) continue;
|
|
593
|
+
|
|
594
|
+
const labels = readServiceLabels(svc.labels);
|
|
595
|
+
const label = labels["openpalm.profile.label"];
|
|
596
|
+
const requires = labels["openpalm.profile.requires"];
|
|
597
|
+
const isDefault = labels["openpalm.profile.default"] === "true";
|
|
598
|
+
|
|
599
|
+
for (const id of profileIds) {
|
|
600
|
+
const existing = byProfile.get(id);
|
|
601
|
+
if (existing) {
|
|
602
|
+
existing.services.push(svcName);
|
|
603
|
+
if (!existing.label && label) existing.label = label;
|
|
604
|
+
if (!existing.requires && requires) existing.requires = requires;
|
|
605
|
+
if (!existing.default && isDefault) existing.default = true;
|
|
606
|
+
} else {
|
|
607
|
+
const profile: AddonProfile = { id, services: [svcName] };
|
|
608
|
+
if (label) profile.label = label;
|
|
609
|
+
if (requires) profile.requires = requires;
|
|
610
|
+
if (isDefault) profile.default = true;
|
|
611
|
+
byProfile.set(id, profile);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return [...byProfile.values()];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function readServiceLabels(raw: unknown): Record<string, string> {
|
|
620
|
+
if (!raw) return {};
|
|
621
|
+
const out: Record<string, string> = {};
|
|
622
|
+
if (Array.isArray(raw)) {
|
|
623
|
+
for (const entry of raw) {
|
|
624
|
+
if (typeof entry !== "string") continue;
|
|
625
|
+
const eq = entry.indexOf("=");
|
|
626
|
+
if (eq < 0) continue;
|
|
627
|
+
out[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
628
|
+
}
|
|
629
|
+
} else if (typeof raw === "object") {
|
|
630
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
631
|
+
if (v == null) continue;
|
|
632
|
+
out[k] = String(v);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function getAddonProfiles(homeDir: string, name: string): AddonProfile[] {
|
|
639
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
640
|
+
|
|
641
|
+
const composeCandidates = [
|
|
642
|
+
join(homeDir, "config", "stack", "addons", name, "compose.yml"),
|
|
643
|
+
join(homeDir, "state", "registry", "addons", name, "compose.yml"),
|
|
644
|
+
];
|
|
645
|
+
|
|
646
|
+
for (const composePath of composeCandidates) {
|
|
647
|
+
const profiles = readAddonProfiles(composePath);
|
|
648
|
+
if (profiles.length > 0) return profiles;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function profileEnvKey(name: string): string {
|
|
655
|
+
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
656
|
+
return `OP_${name.replace(/-/g, '_').toUpperCase()}_PROFILE`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function getAddonProfileSelection(stackDir: string, name: string): string | null {
|
|
660
|
+
const env = readStackEnv(stackDir);
|
|
661
|
+
const value = env[profileEnvKey(name)];
|
|
662
|
+
return value && value.trim() ? value.trim() : null;
|
|
663
|
+
}
|
|
664
|
+
|
|
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');
|
|
668
|
+
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function enableAddon(homeDir: string, name: string): MutationResult {
|
|
343
672
|
try {
|
|
344
673
|
copyAddonFromRegistry(homeDir, name);
|
|
345
674
|
// Pre-create the addon services directory so Docker doesn't create it as root
|
|
@@ -350,7 +679,7 @@ export function enableAddon(homeDir: string, name: string): MutationResult {
|
|
|
350
679
|
}
|
|
351
680
|
}
|
|
352
681
|
|
|
353
|
-
|
|
682
|
+
function disableAddonByName(homeDir: string, name: string): MutationResult {
|
|
354
683
|
try {
|
|
355
684
|
removeEnabledAddon(homeDir, name);
|
|
356
685
|
return { ok: true };
|
|
@@ -29,9 +29,8 @@ type CoreSecretMapping = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const STATIC_CORE_MAPPINGS: CoreSecretMapping[] = [
|
|
32
|
-
// Core authentication
|
|
33
|
-
{ secretKey: 'openpalm/
|
|
34
|
-
{ secretKey: 'openpalm/assistant-token', envKey: 'OP_ASSISTANT_TOKEN', scope: 'system' },
|
|
32
|
+
// Core authentication
|
|
33
|
+
{ secretKey: 'openpalm/ui-login-password', envKey: 'OP_UI_LOGIN_PASSWORD', scope: 'system' },
|
|
35
34
|
{ secretKey: 'openpalm/opencode/server-password', envKey: 'OP_OPENCODE_PASSWORD', scope: 'system' },
|
|
36
35
|
// LLM provider API keys
|
|
37
36
|
{ secretKey: 'openpalm/openai/api-key', envKey: 'OPENAI_API_KEY', scope: 'user' },
|
|
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSyn
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { createLogger } from "../logger.js";
|
|
5
5
|
import { parseEnvFile, mergeEnvContent } from './env.js';
|
|
6
|
+
import { migrateAuth0110 } from './migrate-0110.js';
|
|
6
7
|
import type { ControlPlaneState } from "./types.js";
|
|
7
8
|
import { resolveConfigDir } from "./home.js";
|
|
8
9
|
|
|
@@ -13,8 +14,8 @@ const logger = createLogger("secrets");
|
|
|
13
14
|
/** Keys whose values are shown unmasked in the UI (not secrets). */
|
|
14
15
|
export const PLAIN_CONFIG_KEYS = new Set([
|
|
15
16
|
"OPENAI_BASE_URL",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
17
|
+
"OP_OWNER_NAME",
|
|
18
|
+
"OP_OWNER_EMAIL",
|
|
18
19
|
]);
|
|
19
20
|
|
|
20
21
|
|
|
@@ -58,11 +59,13 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
58
59
|
const existing = existsSync(systemEnvPath) ? parseEnvFile(systemEnvPath) : {};
|
|
59
60
|
const updates: Record<string, string> = {};
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
// OP_UI_LOGIN_PASSWORD seeds the operator login secret. ensureSecrets
|
|
63
|
+
// generates a random fallback the first time so the stack is never
|
|
64
|
+
// installed with an empty password slot; the wizard / CLI install path
|
|
65
|
+
// overwrites it with the operator's chosen value via
|
|
66
|
+
// buildSystemSecretsFromSetup().
|
|
67
|
+
if (!existing.OP_UI_LOGIN_PASSWORD) {
|
|
68
|
+
updates.OP_UI_LOGIN_PASSWORD = randomBytes(32).toString("hex");
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
if (!existsSync(systemEnvPath)) {
|
|
@@ -71,8 +74,7 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
71
74
|
"# All secrets and configuration live here. Advanced users may edit directly.",
|
|
72
75
|
"",
|
|
73
76
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
74
|
-
"
|
|
75
|
-
"OP_ASSISTANT_TOKEN=",
|
|
77
|
+
"OP_UI_LOGIN_PASSWORD=",
|
|
76
78
|
"",
|
|
77
79
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
78
80
|
"OP_OPENCODE_PASSWORD=",
|
|
@@ -89,8 +91,8 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
89
91
|
"LMSTUDIO_API_KEY=",
|
|
90
92
|
"",
|
|
91
93
|
"# ── Owner ────────────────────────────────────────────────────────────",
|
|
92
|
-
`
|
|
93
|
-
`
|
|
94
|
+
`OP_OWNER_NAME=${process.env.OP_OWNER_NAME ?? ""}`,
|
|
95
|
+
`OP_OWNER_EMAIL=${process.env.OP_OWNER_EMAIL ?? ""}`,
|
|
94
96
|
"",
|
|
95
97
|
].join("\n");
|
|
96
98
|
const content = mergeEnvContent(header, updates);
|
|
@@ -104,6 +106,10 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
|
|
|
104
106
|
export function ensureSecrets(state: ControlPlaneState): void {
|
|
105
107
|
enforceVaultDirMode(state.stackDir);
|
|
106
108
|
|
|
109
|
+
// Migrate pre-0.11.0 installs (OP_UI_TOKEN/OP_ASSISTANT_TOKEN → OP_UI_LOGIN_PASSWORD)
|
|
110
|
+
// before any code path that reads OP_UI_LOGIN_PASSWORD sees an empty value.
|
|
111
|
+
migrateAuth0110(state);
|
|
112
|
+
|
|
107
113
|
ensureSystemSecrets(state);
|
|
108
114
|
ensureGuardianEnv(state.stackDir);
|
|
109
115
|
ensureAuthJson(state.configDir);
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
"security": {
|
|
31
31
|
"type": "object",
|
|
32
32
|
"description": "Security settings for the instance.",
|
|
33
|
-
"required": ["
|
|
33
|
+
"required": ["uiLoginPassword"],
|
|
34
34
|
"additionalProperties": false,
|
|
35
35
|
"properties": {
|
|
36
|
-
"
|
|
36
|
+
"uiLoginPassword": {
|
|
37
37
|
"type": "string",
|
|
38
|
-
"description": "
|
|
38
|
+
"description": "Operator login password for the OpenPalm UI. Persisted to stack.env as OP_UI_LOGIN_PASSWORD; the UI's op_session cookie value is compared against it on every authenticated request.",
|
|
39
39
|
"minLength": 8
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -2,6 +2,11 @@ import { parseEnvFile } from './env.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Check if setup is complete by reading config/stack/stack.env.
|
|
5
|
+
*
|
|
6
|
+
* Phase 4 of the auth/proxy refactor replaced the legacy `OP_UI_TOKEN`
|
|
7
|
+
* sentinel with `OP_UI_LOGIN_PASSWORD`. The presence of a non-empty value
|
|
8
|
+
* implies the operator (or the install wizard) has seeded the login
|
|
9
|
+
* secret; `OP_SETUP_COMPLETE=true` is still authoritative when present.
|
|
5
10
|
*/
|
|
6
11
|
export function isSetupComplete(stackDir: string): boolean {
|
|
7
12
|
const parsed = parseEnvFile(`${stackDir}/stack.env`);
|
|
@@ -9,5 +14,5 @@ export function isSetupComplete(stackDir: string): boolean {
|
|
|
9
14
|
return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
return (parsed.
|
|
17
|
+
return (parsed.OP_UI_LOGIN_PASSWORD ?? "").length > 0;
|
|
13
18
|
}
|
|
@@ -34,8 +34,8 @@ export function validateSetupSpec(input: unknown): { valid: boolean; errors: str
|
|
|
34
34
|
function validateSecurity(body: Record<string, unknown>, errors: string[]): void {
|
|
35
35
|
const security = requireObj(body.security, "security object is required", errors);
|
|
36
36
|
if (!security) return;
|
|
37
|
-
if (!requireStr(security, "
|
|
38
|
-
if ((security.
|
|
37
|
+
if (!requireStr(security, "uiLoginPassword", "security.uiLoginPassword is required and must be a non-empty string", errors)) return;
|
|
38
|
+
if ((security.uiLoginPassword as string).length < 8) errors.push("security.uiLoginPassword must be at least 8 characters");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function validateOwner(body: Record<string, unknown>, errors: string[]): void {
|
|
@@ -19,7 +19,7 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
19
19
|
version: 2,
|
|
20
20
|
llm: { provider: "openai", model: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
|
|
21
21
|
embedding: { provider: "openai", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.openai.com/v1" },
|
|
22
|
-
security: {
|
|
22
|
+
security: { uiLoginPassword: "test-admin-token-12345" },
|
|
23
23
|
owner: { name: "Test User", email: "test@example.com" },
|
|
24
24
|
connections: [
|
|
25
25
|
{
|
|
@@ -72,17 +72,17 @@ describe("validateSetupSpec", () => {
|
|
|
72
72
|
expect(result.errors.some((e) => e.includes("security object is required"))).toBe(true);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("rejects missing security.
|
|
75
|
+
it("rejects missing security.uiLoginPassword", () => {
|
|
76
76
|
const spec = makeValidSpec();
|
|
77
|
-
spec.security.
|
|
77
|
+
spec.security.uiLoginPassword = "";
|
|
78
78
|
const result = validateSetupSpec(spec);
|
|
79
79
|
expect(result.valid).toBe(false);
|
|
80
|
-
expect(result.errors.some((e) => e.includes("security.
|
|
80
|
+
expect(result.errors.some((e) => e.includes("security.uiLoginPassword"))).toBe(true);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it("rejects short security.
|
|
83
|
+
it("rejects short security.uiLoginPassword", () => {
|
|
84
84
|
const spec = makeValidSpec();
|
|
85
|
-
spec.security.
|
|
85
|
+
spec.security.uiLoginPassword = "short";
|
|
86
86
|
const result = validateSetupSpec(spec);
|
|
87
87
|
expect(result.valid).toBe(false);
|
|
88
88
|
expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
|
|
@@ -199,9 +199,10 @@ describe("validateSetupSpec", () => {
|
|
|
199
199
|
// ── Tests: buildSecretsFromSetup ─────────────────────────────────────────
|
|
200
200
|
|
|
201
201
|
describe("buildSecretsFromSetup", () => {
|
|
202
|
-
it("does not include
|
|
202
|
+
it("does not include UI login password in user secrets", () => {
|
|
203
203
|
const spec = makeValidSpec();
|
|
204
204
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
205
|
+
expect(secrets.OP_UI_LOGIN_PASSWORD).toBeUndefined();
|
|
205
206
|
expect(secrets.OP_UI_TOKEN).toBeUndefined();
|
|
206
207
|
expect(secrets.ADMIN_TOKEN).toBeUndefined();
|
|
207
208
|
});
|
|
@@ -217,15 +218,15 @@ describe("buildSecretsFromSetup", () => {
|
|
|
217
218
|
it("sets owner info when provided", () => {
|
|
218
219
|
const spec = makeValidSpec();
|
|
219
220
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
220
|
-
expect(secrets.
|
|
221
|
-
expect(secrets.
|
|
221
|
+
expect(secrets.OP_OWNER_NAME).toBe("Test User");
|
|
222
|
+
expect(secrets.OP_OWNER_EMAIL).toBe("test@example.com");
|
|
222
223
|
});
|
|
223
224
|
|
|
224
225
|
it("omits owner info when empty", () => {
|
|
225
226
|
const spec = makeValidSpec({ owner: { name: "", email: "" } });
|
|
226
227
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
227
|
-
expect(secrets.
|
|
228
|
-
expect(secrets.
|
|
228
|
+
expect(secrets.OP_OWNER_NAME).toBeUndefined();
|
|
229
|
+
expect(secrets.OP_OWNER_EMAIL).toBeUndefined();
|
|
229
230
|
});
|
|
230
231
|
|
|
231
232
|
it("does NOT include provider API keys in stack.env updates", () => {
|
|
@@ -304,11 +305,14 @@ describe("buildAuthJsonFromSetup", () => {
|
|
|
304
305
|
});
|
|
305
306
|
|
|
306
307
|
describe("buildSystemSecretsFromSetup", () => {
|
|
307
|
-
|
|
308
|
+
// Phase 4: assistant token was removed; the only stack.env secret this
|
|
309
|
+
// helper writes now is OP_UI_LOGIN_PASSWORD. OP_OPENCODE_PASSWORD is
|
|
310
|
+
// generated by ensureSystemSecrets() and persists across reruns.
|
|
311
|
+
it("returns OP_UI_LOGIN_PASSWORD equal to the supplied operator password", () => {
|
|
308
312
|
const secrets = buildSystemSecretsFromSetup("test-admin-token-12345");
|
|
309
|
-
expect(secrets.
|
|
310
|
-
expect(
|
|
311
|
-
expect(secrets.OP_ASSISTANT_TOKEN).
|
|
313
|
+
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
314
|
+
expect(secrets.OP_UI_TOKEN).toBeUndefined();
|
|
315
|
+
expect(secrets.OP_ASSISTANT_TOKEN).toBeUndefined();
|
|
312
316
|
});
|
|
313
317
|
});
|
|
314
318
|
|
|
@@ -356,15 +360,15 @@ describe("performSetup", () => {
|
|
|
356
360
|
join(stackDir, "stack.env"),
|
|
357
361
|
[
|
|
358
362
|
"OP_SETUP_COMPLETE=false",
|
|
359
|
-
"
|
|
363
|
+
"OP_UI_LOGIN_PASSWORD=",
|
|
360
364
|
"OPENAI_API_KEY=",
|
|
361
365
|
"OPENAI_BASE_URL=",
|
|
362
366
|
"ANTHROPIC_API_KEY=",
|
|
363
367
|
"GROQ_API_KEY=",
|
|
364
368
|
"MISTRAL_API_KEY=",
|
|
365
369
|
"GOOGLE_API_KEY=",
|
|
366
|
-
"
|
|
367
|
-
"
|
|
370
|
+
"OP_OWNER_NAME=",
|
|
371
|
+
"OP_OWNER_EMAIL=",
|
|
368
372
|
"",
|
|
369
373
|
].join("\n")
|
|
370
374
|
);
|
|
@@ -384,13 +388,13 @@ describe("performSetup", () => {
|
|
|
384
388
|
|
|
385
389
|
it("returns an error for invalid input", async () => {
|
|
386
390
|
const result = await performSetup(
|
|
387
|
-
{ security: {
|
|
391
|
+
{ security: { uiLoginPassword: "short" } } as SetupSpec
|
|
388
392
|
);
|
|
389
393
|
expect(result.ok).toBe(false);
|
|
390
394
|
expect(result.error).toBeDefined();
|
|
391
395
|
});
|
|
392
396
|
|
|
393
|
-
it("writes stack.env with the
|
|
397
|
+
it("writes stack.env with the UI login password", async () => {
|
|
394
398
|
const result = await performSetup(makeValidSpec());
|
|
395
399
|
expect(result.ok).toBe(true);
|
|
396
400
|
|