@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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. 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, "state", "assistant"), { recursive: true });
42
- writeFileSync(join(homeDir, "state", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
43
- writeFileSync(join(homeDir, "state", "assistant", "AGENTS.md"), "# Agents\n");
44
- mkdirSync(join(homeDir, "state"), { recursive: true });
45
- // Automations live in state/registry/automations (shipped catalog) and stash/tasks (user tasks)
46
- mkdirSync(join(homeDir, "state", "registry", "automations"), { recursive: true });
47
- writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-logs.md"), "---\nschedule: \"0 4 * * 0\"\ndescription: cleanup logs\n---\n");
48
- writeFileSync(join(homeDir, "state", "registry", "automations", "cleanup-data.md"), "---\nschedule: \"0 5 * * 0\"\ndescription: cleanup data\n---\n");
49
- writeFileSync(join(homeDir, "state", "registry", "automations", "validate-config.md"), "---\nschedule: \"0 3 * * *\"\ndescription: validate config\n---\n");
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
- // 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
+ 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 stateDir: string;
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
- stateDir = join(homeDir, "state");
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, "state", "registry", "automations"),
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, "stash"),
341
+ join(homeDir, "knowledge"),
342
+ join(homeDir, "knowledge", "env"),
343
+ join(homeDir, "knowledge", "secrets"),
345
344
  join(homeDir, "workspace"),
346
- join(homeDir, "cache"),
347
- join(homeDir, "cache", "akm"),
348
- stateDir,
349
- join(stateDir, "assistant"),
350
- join(stateDir, "admin"),
351
- join(stateDir, "guardian"),
352
- join(stateDir, "logs"),
353
- join(stateDir, "logs", "opencode"),
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(stackDir, "stack.env"),
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 stack.env with the UI login password", async () => {
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
- const secretsContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
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
- expect(config.llm.model).toBe("gpt-4o");
413
- expect(config.llm.provider).toBe("openai");
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.provider).toBe("ollama");
452
- expect(config.llm.model).toBe("llama3.2");
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("writes channel credentials to stack.env when channelCredentials provided", async () => {
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
- const stackEnvContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
498
- expect(stackEnvContent).toContain("discord-bot-token-xyz");
499
- expect(stackEnvContent).toContain("discord-app-id-123");
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, renameSync } from "node:fs";
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 to stack.env as
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. Provider API
89
- * keys are NOT included here credentials live in OpenCode's auth.json
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 env update for the wizard / CLI install path.
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
- * `OP_OPENCODE_PASSWORD` is generated by `ensureSystemSecrets()` on first
136
- * run and persists across reruns — it is not regenerated here.
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 stateDir so it is co-located
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.stateDir);
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 existingSystemEnv = readStackEnv(state.stackDir);
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 (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
205
+ if (!channelSecretUpdates[envKey] && process.env[envKey]) channelSecretUpdates[envKey] = process.env[envKey];
226
206
  }
227
207
  }
228
208
  updateSecretsEnv(state, updates);
229
- patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
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
- // Write stack.yml (version marker only)
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.stackDir}/stack.env`)
249
- ? readFileSync(`${state.stackDir}/stack.env`, "utf-8")
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
- if (hostAkm) {
254
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
255
- if (home) {
256
- akmUpdates.OP_AKM_STASH = `${home}/akm`;
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.stackDir}/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
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
- updated.llm = {
280
- ...((existing.llm as Record<string, unknown>) ?? {}),
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 copies the compose overlay AND generates CHANNEL_<NAME>_SECRET in guardian.env
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.md");
341
+ const akmImproveDest = join(tasksDir, "akm-improve.yml");
319
342
  if (!existsSync(akmImproveDest)) {
320
- const akmImproveMd = getRegistryAutomation("akm-improve");
321
- if (akmImproveMd) {
322
- writeFileSync(akmImproveDest, akmImproveMd);
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", {