@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.
Files changed (66) 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 +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. 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 { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
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, "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");
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
- // 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", () => {
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 stateDir: string;
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
- stateDir = join(homeDir, "state");
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, "state", "registry", "automations"),
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, "stash"),
340
+ join(homeDir, "knowledge"),
341
+ join(homeDir, "knowledge", "env"),
342
+ join(homeDir, "knowledge", "secrets"),
345
343
  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"),
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(stackDir, "stack.env"),
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 stack.env with the UI login password", async () => {
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
- const secretsContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
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
- expect(config.llm.model).toBe("gpt-4o");
413
- expect(config.llm.provider).toBe("openai");
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("writes stack.yml v2 version marker", async () => {
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
- const spec = readStackSpec(stackDir);
424
- expect(spec).not.toBeNull();
425
- expect(spec!.version).toBe(2);
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.provider).toBe("ollama");
452
- expect(config.llm.model).toBe("llama3.2");
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("writes stack.yml as version marker only", async () => {
457
- const result = await performSetup(makeValidSpec());
458
- expect(result.ok).toBe(true);
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
- const specPath = join(stackDir, STACK_SPEC_FILENAME);
461
- expect(existsSync(specPath)).toBe(true);
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
- const spec = readStackSpec(stackDir);
464
- expect(spec).not.toBeNull();
465
- expect(spec!.version).toBe(2);
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 spec = readStackSpec(stackDir);
480
- expect(spec).not.toBeNull();
481
- expect(spec!.version).toBe(2);
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("writes channel credentials to stack.env when channelCredentials provided", async () => {
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
- 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");
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, 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,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 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).
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. 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.
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 env update for the wizard / CLI install path.
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
- * `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.
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 stateDir so it is co-located
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.stateDir);
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 existingSystemEnv = readStackEnv(state.stackDir);
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 (!updates[envKey] && process.env[envKey]) updates[envKey] = process.env[envKey];
204
+ if (!channelSecretUpdates[envKey] && process.env[envKey]) channelSecretUpdates[envKey] = process.env[envKey];
226
205
  }
227
206
  }
228
207
  updateSecretsEnv(state, updates);
229
- patchSecretsEnvFile(state.stackDir, buildSystemSecretsFromSetup(security.uiLoginPassword, existingSystemEnv));
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.stackDir}/stack.env`)
249
- ? readFileSync(`${state.stackDir}/stack.env`, "utf-8")
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
- 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
- }
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.stackDir}/stack.env`, mergeEnvContent(systemEnvForAkm, akmUpdates), 0o600);
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
- updated.llm = {
280
- ...((existing.llm as Record<string, unknown>) ?? {}),
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 copies the compose overlay AND generates CHANNEL_<NAME>_SECRET in guardian.env
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.md");
337
+ const akmImproveDest = join(tasksDir, "akm-improve.yml");
319
338
  if (!existsSync(akmImproveDest)) {
320
- const akmImproveMd = getRegistryAutomation("akm-improve");
321
- if (akmImproveMd) {
322
- writeFileSync(akmImproveDest, akmImproveMd);
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", {