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