@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.2
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 +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +67 -30
- package/src/control-plane/compose-args.ts +63 -8
- package/src/control-plane/config-persistence.ts +95 -136
- package/src/control-plane/core-assets.ts +21 -44
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +98 -105
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +37 -36
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +288 -109
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +140 -44
- package/src/control-plane/setup.ts +85 -62
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +49 -12
- package/src/control-plane/stack-spec.test.ts +15 -11
- package/src/control-plane/stack-spec.ts +31 -10
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +130 -0
- package/src/control-plane/ui-assets.ts +132 -57
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +86 -16
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./setup.js";
|
|
12
12
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
13
13
|
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
14
|
+
import { readSecret } from './secrets-files.js';
|
|
14
15
|
|
|
15
16
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -38,15 +39,15 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
|
38
39
|
function seedRequiredAssets(homeDir: string): void {
|
|
39
40
|
mkdirSync(join(homeDir, "config", "stack"), { recursive: true });
|
|
40
41
|
writeFileSync(join(homeDir, "config", "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
|
|
41
|
-
mkdirSync(join(homeDir, "
|
|
42
|
-
writeFileSync(join(homeDir, "
|
|
43
|
-
writeFileSync(join(homeDir, "
|
|
44
|
-
mkdirSync(join(homeDir, "
|
|
45
|
-
// Automations live in
|
|
46
|
-
mkdirSync(join(homeDir, "
|
|
47
|
-
writeFileSync(join(homeDir, "
|
|
48
|
-
writeFileSync(join(homeDir, "
|
|
49
|
-
writeFileSync(join(homeDir, "
|
|
42
|
+
mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
|
|
43
|
+
writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
|
|
44
|
+
writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
|
|
45
|
+
mkdirSync(join(homeDir, "data"), { recursive: true });
|
|
46
|
+
// Automations live in knowledge/tasks as AKM-owned task files.
|
|
47
|
+
mkdirSync(join(homeDir, "data", "registry", "automations"), { recursive: true });
|
|
48
|
+
writeFileSync(join(homeDir, "data", "registry", "automations", "cleanup-logs.yml"), "schedule: \"0 4 * * 0\"\ndescription: cleanup logs\ncommand: [\"echo\",\"clean\"]\n");
|
|
49
|
+
writeFileSync(join(homeDir, "data", "registry", "automations", "cleanup-data.yml"), "schedule: \"0 5 * * 0\"\ndescription: cleanup data\ncommand: [\"echo\",\"clean\"]\n");
|
|
50
|
+
writeFileSync(join(homeDir, "data", "registry", "automations", "validate-config.yml"), "schedule: \"0 3 * * *\"\ndescription: validate config\ncommand: [\"echo\",\"clean\"]\n");
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// ── Tests: validateSetupSpec ────────────────────────────────────────────
|
|
@@ -204,7 +205,6 @@ describe("buildSecretsFromSetup", () => {
|
|
|
204
205
|
const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
|
|
205
206
|
expect(secrets.OP_UI_LOGIN_PASSWORD).toBeUndefined();
|
|
206
207
|
expect(secrets.OP_UI_TOKEN).toBeUndefined();
|
|
207
|
-
expect(secrets.ADMIN_TOKEN).toBeUndefined();
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
it("does not include SYSTEM_LLM_* in user secrets", () => {
|
|
@@ -305,10 +305,7 @@ describe("buildAuthJsonFromSetup", () => {
|
|
|
305
305
|
});
|
|
306
306
|
|
|
307
307
|
describe("buildSystemSecretsFromSetup", () => {
|
|
308
|
-
|
|
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
|
+
it("returns the file-based UI login password update", () => {
|
|
312
309
|
const secrets = buildSystemSecretsFromSetup("test-admin-token-12345");
|
|
313
310
|
expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
|
|
314
311
|
expect(secrets.OP_UI_TOKEN).toBeUndefined();
|
|
@@ -321,7 +318,7 @@ describe("buildSystemSecretsFromSetup", () => {
|
|
|
321
318
|
describe("performSetup", () => {
|
|
322
319
|
let homeDir: string;
|
|
323
320
|
let configDir: string;
|
|
324
|
-
let
|
|
321
|
+
let dataDir: string;
|
|
325
322
|
let stackDir: string;
|
|
326
323
|
|
|
327
324
|
const savedEnv: Record<string, string | undefined> = {};
|
|
@@ -329,44 +326,41 @@ describe("performSetup", () => {
|
|
|
329
326
|
beforeEach(() => {
|
|
330
327
|
homeDir = mkdtempSync(join(tmpdir(), "openpalm-setup-"));
|
|
331
328
|
configDir = join(homeDir, "config");
|
|
332
|
-
|
|
329
|
+
dataDir = join(homeDir, "data");
|
|
333
330
|
stackDir = join(configDir, "stack");
|
|
334
331
|
|
|
335
332
|
// Create required directory structure
|
|
336
333
|
for (const dir of [
|
|
337
334
|
homeDir,
|
|
338
335
|
configDir,
|
|
339
|
-
join(homeDir, "
|
|
336
|
+
join(homeDir, "data", "registry", "automations"),
|
|
340
337
|
join(configDir, "assistant"),
|
|
341
338
|
join(configDir, "akm"),
|
|
342
339
|
stackDir,
|
|
343
340
|
join(stackDir, "addons"),
|
|
344
|
-
join(homeDir, "
|
|
341
|
+
join(homeDir, "knowledge"),
|
|
342
|
+
join(homeDir, "knowledge", "env"),
|
|
343
|
+
join(homeDir, "knowledge", "secrets"),
|
|
345
344
|
join(homeDir, "workspace"),
|
|
346
|
-
|
|
347
|
-
join(
|
|
348
|
-
|
|
349
|
-
join(
|
|
350
|
-
join(
|
|
351
|
-
join(
|
|
352
|
-
join(
|
|
353
|
-
join(
|
|
345
|
+
dataDir,
|
|
346
|
+
join(dataDir, "assistant"),
|
|
347
|
+
join(dataDir, "admin"),
|
|
348
|
+
join(dataDir, "guardian"),
|
|
349
|
+
join(dataDir, "akm", "cache"),
|
|
350
|
+
join(dataDir, "akm", "data"),
|
|
351
|
+
join(dataDir, "logs"),
|
|
352
|
+
join(dataDir, "backups"),
|
|
353
|
+
join(dataDir, "rollback"),
|
|
354
354
|
]) {
|
|
355
355
|
mkdirSync(dir, { recursive: true });
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
// Create stub stack.env so isSetupComplete doesn't crash
|
|
359
359
|
writeFileSync(
|
|
360
|
-
join(
|
|
360
|
+
join(homeDir, "knowledge", "env", "stack.env"),
|
|
361
361
|
[
|
|
362
362
|
"OP_SETUP_COMPLETE=false",
|
|
363
|
-
"OP_UI_LOGIN_PASSWORD=",
|
|
364
|
-
"OPENAI_API_KEY=",
|
|
365
363
|
"OPENAI_BASE_URL=",
|
|
366
|
-
"ANTHROPIC_API_KEY=",
|
|
367
|
-
"GROQ_API_KEY=",
|
|
368
|
-
"MISTRAL_API_KEY=",
|
|
369
|
-
"GOOGLE_API_KEY=",
|
|
370
364
|
"OP_OWNER_NAME=",
|
|
371
365
|
"OP_OWNER_EMAIL=",
|
|
372
366
|
"",
|
|
@@ -394,12 +388,11 @@ describe("performSetup", () => {
|
|
|
394
388
|
expect(result.error).toBeDefined();
|
|
395
389
|
});
|
|
396
390
|
|
|
397
|
-
it("writes
|
|
391
|
+
it("writes the UI login password to knowledge/secrets", async () => {
|
|
398
392
|
const result = await performSetup(makeValidSpec());
|
|
399
393
|
expect(result.ok).toBe(true);
|
|
400
394
|
|
|
401
|
-
|
|
402
|
-
expect(secretsContent).toContain("test-admin-token-12345");
|
|
395
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe("test-admin-token-12345\n");
|
|
403
396
|
});
|
|
404
397
|
|
|
405
398
|
it("writes akm config.json with llm and embedding", async () => {
|
|
@@ -409,11 +402,33 @@ describe("performSetup", () => {
|
|
|
409
402
|
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
410
403
|
expect(existsSync(akmConfigPath)).toBe(true);
|
|
411
404
|
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
412
|
-
|
|
413
|
-
expect(config.llm
|
|
405
|
+
// Canonical akm 0.8.0 shape: profiles.llm.default + defaults.llm — NOT top-level `llm`.
|
|
406
|
+
expect(config.llm).toBeUndefined();
|
|
407
|
+
expect(config.profiles.llm.default.model).toBe("gpt-4o");
|
|
408
|
+
expect(config.profiles.llm.default.provider).toBe("openai");
|
|
409
|
+
expect(config.defaults.llm).toBe("default");
|
|
414
410
|
expect(config.embedding.model).toBe("text-embedding-3-small");
|
|
415
411
|
expect(config.embedding.provider).toBe("openai");
|
|
416
412
|
expect(config.embedding.dimension).toBe(1536);
|
|
413
|
+
// The assistant primary stash is pinned to the bind mount, not operator-set.
|
|
414
|
+
expect(config.stashDir).toBe("/stash");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("does not write the legacy migration-triggering akm config shape (I-3)", async () => {
|
|
418
|
+
// akm's config-migration.ts triggers the legacy 0.7->0.8 shim (which rewrites
|
|
419
|
+
// the file on load) when `isObj(raw.llm) && hasOwn(raw.llm, "endpoint")`. We must
|
|
420
|
+
// write the canonical shape so that condition can NEVER be satisfied — otherwise
|
|
421
|
+
// the assistant's akm config silently rewrites on first load today and becomes a
|
|
422
|
+
// fatal load error when akm removes the shim.
|
|
423
|
+
const result = await performSetup(makeValidSpec());
|
|
424
|
+
expect(result.ok).toBe(true);
|
|
425
|
+
const config = JSON.parse(
|
|
426
|
+
readFileSync(join(homeDir, "config", "akm", "config.json"), "utf-8"),
|
|
427
|
+
) as Record<string, unknown>;
|
|
428
|
+
// The exact migration trigger: a top-level `llm` object carrying `endpoint`.
|
|
429
|
+
const legacyLlm = config.llm as Record<string, unknown> | undefined;
|
|
430
|
+
expect(legacyLlm === undefined || !Object.hasOwn(legacyLlm, "endpoint")).toBe(true);
|
|
431
|
+
expect(config.llm).toBeUndefined();
|
|
417
432
|
});
|
|
418
433
|
|
|
419
434
|
it("writes stack.yml v2 version marker", async () => {
|
|
@@ -448,11 +463,65 @@ describe("performSetup", () => {
|
|
|
448
463
|
|
|
449
464
|
const akmConfigPath = join(homeDir, "config", "akm", "config.json");
|
|
450
465
|
const config = JSON.parse(readFileSync(akmConfigPath, "utf-8"));
|
|
451
|
-
expect(config.llm
|
|
452
|
-
expect(config.llm.
|
|
466
|
+
expect(config.llm).toBeUndefined();
|
|
467
|
+
expect(config.profiles.llm.default.provider).toBe("ollama");
|
|
468
|
+
expect(config.profiles.llm.default.model).toBe("llama3.2");
|
|
469
|
+
expect(config.profiles.llm.default.endpoint).toBe("http://localhost:11434/v1/chat/completions");
|
|
470
|
+
expect(config.defaults.llm).toBe("default");
|
|
453
471
|
expect(config.embedding.dimension).toBe(768);
|
|
454
472
|
});
|
|
455
473
|
|
|
474
|
+
it("auto-enables host akm sharing when host AKM is available (no overlay, no personal-side write)", async () => {
|
|
475
|
+
// HOME must point at a temp dir so we NEVER touch the real ~/.config/akm.
|
|
476
|
+
const fakeHome = mkdtempSync(join(tmpdir(), "openpalm-fakehome-"));
|
|
477
|
+
const savedHome = process.env.HOME;
|
|
478
|
+
process.env.HOME = fakeHome;
|
|
479
|
+
try {
|
|
480
|
+
mkdirSync(join(fakeHome, "akm"), { recursive: true });
|
|
481
|
+
mkdirSync(join(fakeHome, ".config", "akm"), { recursive: true });
|
|
482
|
+
const hostCfgRaw = JSON.stringify({ stashDir: join(fakeHome, "akm") });
|
|
483
|
+
writeFileSync(join(fakeHome, ".config", "akm", "config.json"), hostCfgRaw);
|
|
484
|
+
|
|
485
|
+
const result = await performSetup(makeValidSpec({ hostAkm: true }));
|
|
486
|
+
expect(result.ok).toBe(true);
|
|
487
|
+
|
|
488
|
+
// NO conditional overlay file is produced any more.
|
|
489
|
+
expect(existsSync(join(stackDir, "host-akm.compose.yml"))).toBe(false);
|
|
490
|
+
// OP_HOST_AKM_STASH points at the host stash (mount is always in core.compose.yml).
|
|
491
|
+
expect(readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8")).toContain(
|
|
492
|
+
`OP_HOST_AKM_STASH=${join(fakeHome, "akm")}`,
|
|
493
|
+
);
|
|
494
|
+
// Assistant-side source entry present.
|
|
495
|
+
const opCfg = JSON.parse(readFileSync(join(homeDir, "config", "akm", "config.json"), "utf-8"));
|
|
496
|
+
expect((opCfg.sources as Array<Record<string, unknown>>).some((s) => s.name === "host-akm")).toBe(true);
|
|
497
|
+
// D1: the personal config is NEVER written (byte-for-byte unchanged, no `openpalm` source).
|
|
498
|
+
expect(readFileSync(join(fakeHome, ".config", "akm", "config.json"), "utf-8")).toBe(hostCfgRaw);
|
|
499
|
+
} finally {
|
|
500
|
+
if (savedHome !== undefined) process.env.HOME = savedHome;
|
|
501
|
+
else delete process.env.HOME;
|
|
502
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("does not enable (and does not fail) when host AKM is not available; mount falls back to empty dir", async () => {
|
|
507
|
+
const fakeHome = mkdtempSync(join(tmpdir(), "openpalm-fakehome-"));
|
|
508
|
+
const savedHome = process.env.HOME;
|
|
509
|
+
process.env.HOME = fakeHome;
|
|
510
|
+
try {
|
|
511
|
+
// No ~/.config/akm/config.json → not available.
|
|
512
|
+
const result = await performSetup(makeValidSpec({ hostAkm: true }));
|
|
513
|
+
expect(result.ok).toBe(true);
|
|
514
|
+
// No source entry added, and OP_HOST_AKM_STASH left unset (→ compose empty-dir fallback).
|
|
515
|
+
const opCfg = JSON.parse(readFileSync(join(homeDir, "config", "akm", "config.json"), "utf-8"));
|
|
516
|
+
expect((opCfg.sources ?? []).some((s: { name?: string }) => s.name === "host-akm")).toBe(false);
|
|
517
|
+
expect(readFileSync(join(homeDir, "knowledge", "env", "stack.env"), "utf-8")).not.toContain("OP_HOST_AKM_STASH=");
|
|
518
|
+
} finally {
|
|
519
|
+
if (savedHome !== undefined) process.env.HOME = savedHome;
|
|
520
|
+
else delete process.env.HOME;
|
|
521
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
456
525
|
it("writes stack.yml as version marker only", async () => {
|
|
457
526
|
const result = await performSetup(makeValidSpec());
|
|
458
527
|
expect(result.ok).toBe(true);
|
|
@@ -479,9 +548,16 @@ describe("performSetup", () => {
|
|
|
479
548
|
const spec = readStackSpec(stackDir);
|
|
480
549
|
expect(spec).not.toBeNull();
|
|
481
550
|
expect(spec!.version).toBe(2);
|
|
551
|
+
|
|
552
|
+
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
|
|
553
|
+
expect(stackEnv).not.toContain('OPENAI_API_KEY=');
|
|
554
|
+
expect(readSecret(stackDir, 'openai_api_key')).toBeNull();
|
|
555
|
+
|
|
556
|
+
const authJson = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), 'utf-8')) as Record<string, { key: string }>;
|
|
557
|
+
expect(authJson.openai.key).toBe('sk-secondary');
|
|
482
558
|
});
|
|
483
559
|
|
|
484
|
-
it("
|
|
560
|
+
it("splits channel credentials between secret files and stack.env", async () => {
|
|
485
561
|
const input = makeValidSpec({
|
|
486
562
|
channelCredentials: {
|
|
487
563
|
discord: {
|
|
@@ -494,8 +570,28 @@ describe("performSetup", () => {
|
|
|
494
570
|
const result = await performSetup(input);
|
|
495
571
|
expect(result.ok).toBe(true);
|
|
496
572
|
|
|
497
|
-
|
|
498
|
-
expect(
|
|
499
|
-
|
|
573
|
+
expect(readSecret(stackDir, 'discord_bot_token')).toBe("discord-bot-token-xyz\n");
|
|
574
|
+
expect(readSecret(stackDir, 'discord_application_id')).toBeNull();
|
|
575
|
+
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
|
|
576
|
+
expect(stackEnv).toContain('DISCORD_APPLICATION_ID=discord-app-id-123');
|
|
577
|
+
expect(stackEnv).not.toContain('DISCORD_BOT_TOKEN=');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("ensureOpenCodeConfig never writes forbidden keys (providers, smallModel, model) to the user config", async () => {
|
|
581
|
+
// OpenCode v1.2.24+ rejects these keys with ConfigInvalidError at startup.
|
|
582
|
+
// This test locks the starter config shape so future changes can't
|
|
583
|
+
// accidentally introduce keys that would crash the assistant on boot.
|
|
584
|
+
const { ensureOpenCodeConfig } = await import("./secrets.js");
|
|
585
|
+
ensureOpenCodeConfig();
|
|
586
|
+
|
|
587
|
+
const configPath = join(homeDir, "config", "assistant", "opencode.json");
|
|
588
|
+
expect(existsSync(configPath)).toBe(true);
|
|
589
|
+
|
|
590
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
591
|
+
expect(config).not.toHaveProperty("providers");
|
|
592
|
+
expect(config).not.toHaveProperty("smallModel");
|
|
593
|
+
expect(config).not.toHaveProperty("model");
|
|
594
|
+
// $schema is the only required key
|
|
595
|
+
expect(config.$schema).toBeTruthy();
|
|
500
596
|
});
|
|
501
597
|
});
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* This module does NOT include Docker operations (compose up, image pull, etc.)
|
|
6
6
|
* — those happen separately in the caller after setup completes.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { createLogger } from "../logger.js";
|
|
11
|
+
import { writeFileAtomic } from "./fs-atomic.js";
|
|
12
|
+
import { enableHostAkmSharing, ensureHostStashEnv, isHostAkmAvailable } from "./host-akm-sharing.js";
|
|
11
13
|
import {
|
|
12
14
|
PROVIDER_KEY_MAP,
|
|
13
15
|
} from "../provider-constants.js";
|
|
@@ -19,33 +21,18 @@ import {
|
|
|
19
21
|
updateSecretsEnv,
|
|
20
22
|
patchSecretsEnvFile,
|
|
21
23
|
ensureOpenCodeConfig,
|
|
22
|
-
readStackEnv,
|
|
23
24
|
writeAuthJsonProviderKeys,
|
|
24
25
|
} from "./secrets.js";
|
|
25
26
|
import { createState } from "./lifecycle.js";
|
|
26
|
-
import { writeStackSpec } from "./stack-spec.js";
|
|
27
|
+
import { readStackSpec, writeStackSpec } from "./stack-spec.js";
|
|
27
28
|
import { writeVoiceVars } from "./spec-to-env.js";
|
|
28
29
|
import type { ControlPlaneState } from "./types.js";
|
|
29
30
|
import { validateSetupSpec } from "./setup-validation.js";
|
|
30
|
-
import { getRegistryAutomation, setAddonEnabled } from "./registry.js";
|
|
31
|
+
import { getRegistryAutomation, setAddonEnabled, setAddonProfileSelection } from "./registry.js";
|
|
31
32
|
export { validateSetupSpec } from "./setup-validation.js";
|
|
32
33
|
|
|
33
34
|
const logger = createLogger("setup");
|
|
34
35
|
|
|
35
|
-
// ── Atomic write helper ──────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Write `content` to `path` atomically: write to `path.tmp` first, then
|
|
39
|
-
* rename over the target. On POSIX this rename is atomic — a reader always
|
|
40
|
-
* sees either the old file or the new file, never a partially-written one.
|
|
41
|
-
* If the tmp write fails the original file is untouched.
|
|
42
|
-
*/
|
|
43
|
-
function writeFileAtomic(path: string, content: string | Uint8Array, mode?: number): void {
|
|
44
|
-
const tmp = `${path}.tmp`;
|
|
45
|
-
writeFileSync(tmp, content, mode !== undefined ? { mode } : {});
|
|
46
|
-
renameSync(tmp, path);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
36
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
50
37
|
|
|
51
38
|
export type SetupConnection = {
|
|
@@ -69,15 +56,15 @@ export type SetupSpec = {
|
|
|
69
56
|
tts?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; voice?: string };
|
|
70
57
|
stt?: { enabled?: boolean; engine?: string; provider?: string; baseURL?: string; model?: string; language?: string };
|
|
71
58
|
/**
|
|
72
|
-
* Operator-supplied UI login password. Persisted
|
|
73
|
-
* `OP_UI_LOGIN_PASSWORD`. Replaces the legacy `adminToken` field
|
|
74
|
-
* (Phase 4 of docs/technical/auth-and-proxy-refactor-plan.md).
|
|
59
|
+
* Operator-supplied UI login password. Persisted as a file-based secret.
|
|
75
60
|
*/
|
|
76
61
|
security: { uiLoginPassword: string };
|
|
77
62
|
owner?: { name?: string; email?: string };
|
|
78
63
|
connections: SetupConnection[];
|
|
79
64
|
channelCredentials?: Record<string, Record<string, string>>;
|
|
80
65
|
addons?: Record<string, boolean>;
|
|
66
|
+
voiceProfile?: string;
|
|
67
|
+
ollamaProfile?: string;
|
|
81
68
|
imageTag?: string;
|
|
82
69
|
hostAkm?: boolean;
|
|
83
70
|
};
|
|
@@ -85,10 +72,8 @@ export type SetupSpec = {
|
|
|
85
72
|
// ── Secrets Builder ──────────────────────────────────────────────────────
|
|
86
73
|
|
|
87
74
|
/**
|
|
88
|
-
* Build the stack.env update payload from a setup spec.
|
|
89
|
-
*
|
|
90
|
-
* (see buildAuthJsonFromSetup), not stack.env. This function returns
|
|
91
|
-
* only non-credential vars: owner identity and similar.
|
|
75
|
+
* Build the non-secret stack.env update payload from a setup spec.
|
|
76
|
+
* Provider API keys and channel credentials are written as file-based secrets.
|
|
92
77
|
*/
|
|
93
78
|
export function buildSecretsFromSetup(
|
|
94
79
|
connections: SetupConnection[],
|
|
@@ -124,7 +109,7 @@ export function buildAuthJsonFromSetup(
|
|
|
124
109
|
}
|
|
125
110
|
|
|
126
111
|
/**
|
|
127
|
-
* Build the system-secret
|
|
112
|
+
* Build the system-secret update for the wizard / CLI install path.
|
|
128
113
|
*
|
|
129
114
|
* Phase 4 of the auth/proxy refactor collapsed the legacy
|
|
130
115
|
* `OP_UI_TOKEN` / `OP_ASSISTANT_TOKEN` pair into a single operator login
|
|
@@ -132,15 +117,11 @@ export function buildAuthJsonFromSetup(
|
|
|
132
117
|
* password; `requireAdmin()` compares the cookie against
|
|
133
118
|
* `process.env.OP_UI_LOGIN_PASSWORD` via the existing `safeTokenCompare`.
|
|
134
119
|
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
*
|
|
138
|
-
* `existingSystemEnv` is unused now but the parameter is kept so callers
|
|
139
|
-
* compile unchanged. It can be removed in a follow-up cleanup.
|
|
120
|
+
* `OP_OPENCODE_PASSWORD` may be supplied explicitly as a file-based secret in
|
|
121
|
+
* `knowledge/secrets/op_opencode_password` when OpenCode auth is enabled.
|
|
140
122
|
*/
|
|
141
123
|
export function buildSystemSecretsFromSetup(
|
|
142
124
|
uiLoginPassword: string,
|
|
143
|
-
_existingSystemEnv: Record<string, string> = {}
|
|
144
125
|
): Record<string, string> {
|
|
145
126
|
return {
|
|
146
127
|
OP_UI_LOGIN_PASSWORD: uiLoginPassword,
|
|
@@ -192,13 +173,13 @@ export async function performSetup(
|
|
|
192
173
|
const validation = validateSetupSpec(input);
|
|
193
174
|
if (!validation.valid) return { ok: false, error: validation.errors.join("; ") };
|
|
194
175
|
|
|
195
|
-
const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, imageTag, hostAkm } = input;
|
|
176
|
+
const { llm, embedding, tts, stt, security, owner, connections, channelCredentials, addons, voiceProfile, ollamaProfile, imageTag, hostAkm } = input;
|
|
196
177
|
const state = opts?.state ?? createState();
|
|
197
178
|
|
|
198
179
|
// Acquire install lock to prevent two concurrent setup runs from racing on
|
|
199
|
-
// the same config directory. The lock lives in
|
|
180
|
+
// the same config directory. The lock lives in dataDir so it is co-located
|
|
200
181
|
// with runtime state and the same path startDeploy uses.
|
|
201
|
-
const lockHandle: InstallLockHandle | null = acquireInstallLock(state.
|
|
182
|
+
const lockHandle: InstallLockHandle | null = acquireInstallLock(state.dataDir);
|
|
202
183
|
if (lockHandle === null) {
|
|
203
184
|
return {
|
|
204
185
|
ok: false,
|
|
@@ -217,16 +198,16 @@ export async function performSetup(
|
|
|
217
198
|
try {
|
|
218
199
|
ensureHomeDirs();
|
|
219
200
|
ensureSecrets(state);
|
|
220
|
-
const
|
|
221
|
-
if (channelCredentials) Object.assign(updates, buildChannelCredentialEnvVars(channelCredentials));
|
|
201
|
+
const channelSecretUpdates = channelCredentials ? buildChannelCredentialEnvVars(channelCredentials) : {};
|
|
222
202
|
// Pick up channel credential env vars not already provided in the spec
|
|
223
203
|
for (const mapping of Object.values(CHANNEL_CREDENTIAL_ENV_MAP)) {
|
|
224
204
|
for (const envKey of Object.values(mapping)) {
|
|
225
|
-
if (!
|
|
205
|
+
if (!channelSecretUpdates[envKey] && process.env[envKey]) channelSecretUpdates[envKey] = process.env[envKey];
|
|
226
206
|
}
|
|
227
207
|
}
|
|
228
208
|
updateSecretsEnv(state, updates);
|
|
229
|
-
|
|
209
|
+
updateSecretsEnv(state, channelSecretUpdates);
|
|
210
|
+
patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword));
|
|
230
211
|
// Provider API keys land in OpenCode's auth.json (bind-mounted into
|
|
231
212
|
// the assistant container) — never in stack.env.
|
|
232
213
|
writeAuthJsonProviderKeys(state, providerKeys);
|
|
@@ -240,28 +221,22 @@ export async function performSetup(
|
|
|
240
221
|
// single try/catch so that a disk-full or permission-denied mid-way returns a
|
|
241
222
|
// clean error rather than leaving a broken half-installed ~/.openpalm/.
|
|
242
223
|
try {
|
|
243
|
-
//
|
|
244
|
-
writeStackSpec(state.stackDir, { version: 2 });
|
|
224
|
+
// Preserve addon enablement while refreshing the stack schema marker.
|
|
225
|
+
writeStackSpec(state.stackDir, readStackSpec(state.stackDir) ?? { version: 2 });
|
|
245
226
|
|
|
246
227
|
// Write image tag and AKM mount paths to stack.env — atomic to avoid
|
|
247
228
|
// partial writes if the process is interrupted mid-write.
|
|
248
|
-
const systemEnvForAkm = existsSync(`${state.
|
|
249
|
-
? readFileSync(`${state.
|
|
229
|
+
const systemEnvForAkm = existsSync(`${state.stashDir}/env/stack.env`)
|
|
230
|
+
? readFileSync(`${state.stashDir}/env/stack.env`, "utf-8")
|
|
250
231
|
: "";
|
|
251
232
|
const akmUpdates: Record<string, string> = {};
|
|
252
233
|
if (imageTag) akmUpdates.OP_IMAGE_TAG = imageTag;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
akmUpdates.OP_AKM_DATA = `${home}/.local/share/akm`;
|
|
258
|
-
akmUpdates.OP_AKM_STATE = `${home}/.local/state/akm`;
|
|
259
|
-
akmUpdates.OP_AKM_CACHE = `${home}/.cache/akm`;
|
|
260
|
-
akmUpdates.OP_AKM_CONFIG = `${home}/.config/akm`;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
234
|
+
// NOTE: host-akm sharing no longer repoints the container's primary stash
|
|
235
|
+
// (the old OP_AKM_STASH/OP_AKM_CONFIG split-brain). The personal ~/akm is
|
|
236
|
+
// wired as a read-write SECONDARY source — see configureHostAkmSharing()
|
|
237
|
+
// below (Phase 4) and the host-akm.compose.yml overlay.
|
|
263
238
|
if (Object.keys(akmUpdates).length > 0) {
|
|
264
|
-
writeFileAtomic(`${state.
|
|
239
|
+
writeFileAtomic(`${state.stashDir}/env/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
|
|
265
240
|
}
|
|
266
241
|
|
|
267
242
|
// Write akm config with LLM and embedding settings from setup — atomic.
|
|
@@ -276,12 +251,27 @@ export async function performSetup(
|
|
|
276
251
|
const updated = { ...existing };
|
|
277
252
|
if (llm) {
|
|
278
253
|
const base = llm.baseUrl ? llm.baseUrl.replace(/\/+$/, "") : "";
|
|
279
|
-
|
|
280
|
-
|
|
254
|
+
// Write the CANONICAL akm 0.8.0 shape: profiles.llm.default + defaults.llm.
|
|
255
|
+
// The runtime resolver reads profiles.llm[defaults.llm] (akm config.ts).
|
|
256
|
+
// Do NOT write a top-level `llm` — akm's top-level schema is .strict()
|
|
257
|
+
// with no `llm` key (config-schema.ts AkmConfigShape). A top-level `llm`
|
|
258
|
+
// only loads today via akm's legacy 0.7→0.8 migration shim
|
|
259
|
+
// (config-migration.ts), which rewrites the file on load and is marked
|
|
260
|
+
// for removal — writing the native shape removes that dependency.
|
|
261
|
+
const profiles = (updated.profiles as Record<string, unknown>) ?? {};
|
|
262
|
+
const llmProfiles = (profiles.llm as Record<string, unknown>) ?? {};
|
|
263
|
+
llmProfiles.default = {
|
|
264
|
+
...((llmProfiles.default as Record<string, unknown>) ?? {}),
|
|
281
265
|
endpoint: base ? `${base}/chat/completions` : "",
|
|
282
266
|
model: llm.model,
|
|
283
267
|
provider: llm.provider,
|
|
284
268
|
};
|
|
269
|
+
profiles.llm = llmProfiles;
|
|
270
|
+
updated.profiles = profiles;
|
|
271
|
+
const defaults = (updated.defaults as Record<string, unknown>) ?? {};
|
|
272
|
+
if (typeof defaults.llm !== "string") defaults.llm = "default";
|
|
273
|
+
updated.defaults = defaults;
|
|
274
|
+
delete (updated as Record<string, unknown>).llm; // never persist the legacy key
|
|
285
275
|
}
|
|
286
276
|
if (embedding) {
|
|
287
277
|
const base = embedding.baseUrl ? embedding.baseUrl.replace(/\/+$/, "") : "";
|
|
@@ -293,33 +283,66 @@ export async function performSetup(
|
|
|
293
283
|
dimension: embedding.dims,
|
|
294
284
|
};
|
|
295
285
|
}
|
|
286
|
+
// The assistant's primary stash is ALWAYS /stash (the bind mount). Pin it
|
|
287
|
+
// in config so it is explicit and operator-edits can't repoint it; the UI
|
|
288
|
+
// does not expose stashDir. (The host task-runner still uses its own
|
|
289
|
+
// AKM_STASH_DIR env, which takes precedence over config.stashDir.)
|
|
290
|
+
updated.stashDir = "/stash";
|
|
296
291
|
writeFileAtomic(akmConfigPath, JSON.stringify(updated, null, 2), 0o600);
|
|
297
292
|
}
|
|
298
293
|
|
|
294
|
+
// Host AKM sharing. /host-stash is ALWAYS mounted (core.compose.yml, with
|
|
295
|
+
// an empty-dir fallback). ensureHostStashEnv points OP_HOST_AKM_STASH at
|
|
296
|
+
// the user's ~/akm when host AKM is available, else unsets it (→ empty dir).
|
|
297
|
+
// "Sharing" is just a writable secondary source entry; auto-enabled when the
|
|
298
|
+
// host has AKM and the wizard didn't opt out.
|
|
299
|
+
ensureHostStashEnv(state);
|
|
300
|
+
if (hostAkm !== false && isHostAkmAvailable()) {
|
|
301
|
+
try {
|
|
302
|
+
const { profilesImported } = enableHostAkmSharing(state, {
|
|
303
|
+
writable: true,
|
|
304
|
+
importProfiles: !llm, // import host profiles only if the wizard set none
|
|
305
|
+
});
|
|
306
|
+
logger.info("host akm sharing auto-enabled during setup", { profilesImported });
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// Non-fatal — the mount is present regardless; user can enable from the admin tab.
|
|
309
|
+
logger.warn("host akm sharing could not be enabled", { error: (err as Error).message });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
299
313
|
// Write TTS/STT vars to stack.env for the voice channel
|
|
300
314
|
if (tts || stt) {
|
|
301
315
|
writeVoiceVars({ tts, stt }, state.stackDir);
|
|
302
316
|
}
|
|
303
317
|
|
|
304
318
|
// Enable requested addons (channels like discord, slack, etc.)
|
|
305
|
-
// setAddonEnabled
|
|
319
|
+
// setAddonEnabled records explicit activation state and ensures channel secret files.
|
|
306
320
|
if (addons) {
|
|
307
321
|
for (const [name, enabled] of Object.entries(addons)) {
|
|
308
|
-
if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true);
|
|
322
|
+
if (enabled) setAddonEnabled(state.homeDir, state.stackDir, name, true, state);
|
|
309
323
|
}
|
|
310
324
|
}
|
|
311
325
|
|
|
326
|
+
|
|
327
|
+
if (voiceProfile?.trim()) {
|
|
328
|
+
setAddonProfileSelection(state.stackDir, 'voice', voiceProfile.trim(), state);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (ollamaProfile?.trim()) {
|
|
332
|
+
setAddonProfileSelection(state.stackDir, 'ollama', ollamaProfile.trim(), state);
|
|
333
|
+
}
|
|
334
|
+
|
|
312
335
|
ensureOpenCodeConfig();
|
|
313
336
|
|
|
314
337
|
// Seed default automation into the AKM stash. Idempotent — existing files
|
|
315
338
|
// are left alone so user edits survive re-install and upgrade.
|
|
316
339
|
const tasksDir = join(state.stashDir, "tasks");
|
|
317
340
|
mkdirSync(tasksDir, { recursive: true });
|
|
318
|
-
const akmImproveDest = join(tasksDir, "akm-improve.
|
|
341
|
+
const akmImproveDest = join(tasksDir, "akm-improve.yml");
|
|
319
342
|
if (!existsSync(akmImproveDest)) {
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
writeFileSync(akmImproveDest,
|
|
343
|
+
const akmImproveTask = getRegistryAutomation("akm-improve");
|
|
344
|
+
if (akmImproveTask) {
|
|
345
|
+
writeFileSync(akmImproveDest, akmImproveTask);
|
|
323
346
|
logger.info("seeded default automation", { name: "akm-improve" });
|
|
324
347
|
} else {
|
|
325
348
|
logger.warn("default automation missing from registry; skipping seed", {
|