@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.14

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 (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-user-env.test.ts +113 -0
  4. package/src/control-plane/akm-user-env.ts +144 -0
  5. package/src/control-plane/backup.ts +14 -5
  6. package/src/control-plane/channels.ts +48 -29
  7. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  8. package/src/control-plane/compose-args.test.ts +90 -31
  9. package/src/control-plane/compose-args.ts +119 -9
  10. package/src/control-plane/config-persistence.ts +87 -133
  11. package/src/control-plane/core-assets.test.ts +9 -9
  12. package/src/control-plane/core-assets.ts +24 -8
  13. package/src/control-plane/docker.ts +15 -14
  14. package/src/control-plane/env.test.ts +10 -10
  15. package/src/control-plane/env.ts +1 -1
  16. package/src/control-plane/extends-support.test.ts +8 -8
  17. package/src/control-plane/home.ts +34 -46
  18. package/src/control-plane/host-opencode.test.ts +82 -10
  19. package/src/control-plane/host-opencode.ts +42 -13
  20. package/src/control-plane/install-edge-cases.test.ts +94 -102
  21. package/src/control-plane/install-lock.ts +7 -7
  22. package/src/control-plane/lifecycle.ts +36 -34
  23. package/src/control-plane/markdown-task.ts +30 -50
  24. package/src/control-plane/paths.ts +62 -42
  25. package/src/control-plane/profile-ids.ts +21 -0
  26. package/src/control-plane/provider-models.ts +3 -3
  27. package/src/control-plane/registry.test.ts +97 -88
  28. package/src/control-plane/registry.ts +142 -109
  29. package/src/control-plane/rollback.ts +8 -38
  30. package/src/control-plane/scheduler.ts +7 -7
  31. package/src/control-plane/secret-audit.test.ts +159 -0
  32. package/src/control-plane/secret-audit.ts +255 -0
  33. package/src/control-plane/secret-mappings.ts +2 -2
  34. package/src/control-plane/secrets-files.test.ts +60 -0
  35. package/src/control-plane/secrets-files.ts +66 -0
  36. package/src/control-plane/secrets.ts +113 -86
  37. package/src/control-plane/setup-config.schema.json +1 -1
  38. package/src/control-plane/setup-status.ts +6 -11
  39. package/src/control-plane/setup.test.ts +42 -40
  40. package/src/control-plane/setup.ts +36 -31
  41. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  42. package/src/control-plane/spec-to-env.test.ts +22 -17
  43. package/src/control-plane/spec-to-env.ts +7 -2
  44. package/src/control-plane/stack-spec.test.ts +10 -0
  45. package/src/control-plane/stack-spec.ts +28 -1
  46. package/src/control-plane/types.ts +2 -4
  47. package/src/control-plane/ui-assets.ts +60 -58
  48. package/src/control-plane/validate.ts +13 -15
  49. package/src/index.ts +47 -15
  50. package/src/control-plane/akm-vault.test.ts +0 -105
  51. package/src/control-plane/akm-vault.ts +0 -311
  52. package/src/control-plane/migrate-0110.test.ts +0 -177
  53. package/src/control-plane/migrate-0110.ts +0 -99
  54. package/src/control-plane/registry-components.test.ts +0 -391
package/README.md CHANGED
@@ -21,7 +21,7 @@ Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
21
21
  ## Important context
22
22
 
23
23
  - Some filenames still use legacy names like `staging`; those modules now support the direct-write compose model
24
- - `config/` is user-owned, `config/stack/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
24
+ - `config/` is user-owned, `knowledge/env/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
25
25
  - New reusable control-plane logic belongs here, not duplicated in consumers
26
26
 
27
27
  ## Main module areas
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-beta.11",
3
+ "version": "0.11.0-beta.14",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tests for the akm user-env helpers (`env:user`).
3
+ *
4
+ * akm (>= 0.8.0) no longer manages individual env entries, so OpenPalm owns the
5
+ * `knowledge/env/user.env` file directly. Writes/deletes are plain atomic .env
6
+ * edits — no akm subprocess — so these tests run everywhere (no akm-on-PATH gate).
7
+ */
8
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, statSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ ensureAkmUserEnv,
14
+ readUserEnvSync,
15
+ writeUserEnvKey,
16
+ deleteUserEnvKey,
17
+ userEnvPathSync,
18
+ AKM_USER_ENV_REF,
19
+ } from "./akm-user-env.js";
20
+ import type { ControlPlaneState } from "./types.js";
21
+
22
+ function makeState(homeDir: string): ControlPlaneState {
23
+ return {
24
+ homeDir,
25
+ configDir: join(homeDir, "config"),
26
+ stashDir: join(homeDir, "knowledge"),
27
+ workspaceDir: join(homeDir, "workspace"),
28
+ dataDir: join(homeDir, "data"),
29
+ stackDir: join(homeDir, "config", "stack"),
30
+ services: {},
31
+ artifacts: { compose: "" },
32
+ artifactMeta: [],
33
+ };
34
+ }
35
+
36
+ describe("akm user-env helpers", () => {
37
+ let homeDir: string;
38
+ let state: ControlPlaneState;
39
+
40
+ beforeEach(() => {
41
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-akm-env-"));
42
+ state = makeState(homeDir);
43
+ mkdirSync(state.stashDir, { recursive: true });
44
+ });
45
+
46
+ afterEach(() => {
47
+ rmSync(homeDir, { recursive: true, force: true });
48
+ });
49
+
50
+ it("ensureAkmUserEnv creates env/user.env (mode 0600) and returns its path", () => {
51
+ const path = ensureAkmUserEnv(state);
52
+ expect(path).toBe(userEnvPathSync(state));
53
+ expect(path).toBe(join(state.stashDir, "env", "user.env"));
54
+ expect(existsSync(path)).toBe(true);
55
+ expect(statSync(path).mode & 0o777).toBe(0o600);
56
+ });
57
+
58
+ it("writeUserEnvKey upserts a key, readUserEnvSync reads it back", () => {
59
+ writeUserEnvKey(state, "TOKEN", "secret-9988");
60
+ expect(readUserEnvSync(state).TOKEN).toBe("secret-9988");
61
+
62
+ // Upsert replaces in place rather than appending a duplicate.
63
+ writeUserEnvKey(state, "TOKEN", "rotated");
64
+ const parsed = readUserEnvSync(state);
65
+ expect(parsed.TOKEN).toBe("rotated");
66
+ const lines = readFileSync(userEnvPathSync(state), "utf-8").split("\n").filter((l) => l.startsWith("TOKEN="));
67
+ expect(lines.length).toBe(1);
68
+ });
69
+
70
+ it("writeUserEnvKey single-quotes values with spaces/special chars (shell-source-safe, dotenv round-trips)", () => {
71
+ writeUserEnvKey(state, "TOKEN", "sk-simple123");
72
+ writeUserEnvKey(state, "OWNER", "Ada Lovelace");
73
+ writeUserEnvKey(state, "URL", "https://x.example/p?a=1&b=2");
74
+ writeUserEnvKey(state, "NOTE", "a#b$c");
75
+
76
+ // dotenv round-trip (what akm env run / the admin endpoint parse).
77
+ const parsed = readUserEnvSync(state);
78
+ expect(parsed.OWNER).toBe("Ada Lovelace");
79
+ expect(parsed.URL).toBe("https://x.example/p?a=1&b=2");
80
+ expect(parsed.NOTE).toBe("a#b$c");
81
+
82
+ // Raw lines: simple tokens stay bare; anything with spaces/shell-meta is
83
+ // POSIX single-quoted so the entrypoint's `set -a; . user.env` is safe
84
+ // (no word-splitting, no `&`/`$` interpretation, no injection).
85
+ const raw = readFileSync(userEnvPathSync(state), "utf-8");
86
+ expect(raw).toContain("TOKEN=sk-simple123\n");
87
+ expect(raw).toContain("OWNER='Ada Lovelace'\n");
88
+ expect(raw).toContain("URL='https://x.example/p?a=1&b=2'\n");
89
+ });
90
+
91
+ it("deleteUserEnvKey removes only the named key", () => {
92
+ writeUserEnvKey(state, "TOKEN_A", "value-a");
93
+ writeUserEnvKey(state, "TOKEN_B", "value-b");
94
+ deleteUserEnvKey(state, "TOKEN_A");
95
+ const parsed = readUserEnvSync(state);
96
+ expect(parsed.TOKEN_A).toBeUndefined();
97
+ expect(parsed.TOKEN_B).toBe("value-b");
98
+ });
99
+
100
+ it("deleteUserEnvKey is idempotent on a missing key", () => {
101
+ expect(() => deleteUserEnvKey(state, "NEVER_SET_KEY")).not.toThrow();
102
+ });
103
+
104
+ it("readUserEnvSync returns {} when no file exists yet", () => {
105
+ expect(readUserEnvSync(state)).toEqual({});
106
+ });
107
+ });
108
+
109
+ describe("AKM_USER_ENV_REF", () => {
110
+ it("exports the canonical akm ref string", () => {
111
+ expect(AKM_USER_ENV_REF).toBe("env:user");
112
+ });
113
+ });
@@ -0,0 +1,144 @@
1
+ /// <reference types="bun-types" />
2
+ /**
3
+ * akm `env:user` helpers.
4
+ *
5
+ * The user-managed environment file lives at `${OP_HOME}/knowledge/env/user.env`
6
+ * and is the canonical home for user-managed configuration (LLM provider keys,
7
+ * owner info, and any other user-set values). It maps to the akm `env` asset
8
+ * type (ref `env:user`): a whole `.env` file that akm loads wholesale via
9
+ * `akm env run env:user` / `akm env path env:user`. The assistant entrypoint
10
+ * sources this file directly at startup.
11
+ *
12
+ * akm (>= 0.8.0) no longer manages individual env entries — the file owner edits
13
+ * it and akm loads it as a unit. OpenPalm therefore owns the file directly:
14
+ * writes/deletes are plain atomic .env edits (mode 0600), no akm subprocess.
15
+ * Values are shell-quoted on write so the entrypoint can `source` the file
16
+ * safely; `parseEnvFile` (dotenv) unquotes them on read.
17
+ *
18
+ * `stack.env` and `knowledge/secrets/` are operator-managed and NOT part of
19
+ * this file; service secrets are granted as Compose secret files.
20
+ *
21
+ * Layout:
22
+ * knowledge/ — AKM_STASH_DIR: asset content (skills, env, secrets, agents)
23
+ * data/akm/ — akm operational cache and data
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
26
+ import { dirname } from "node:path";
27
+ import { parseEnvFile, upsertEnvValue, removeEnvKey } from "./env.js";
28
+ import type { ControlPlaneState } from "./types.js";
29
+
30
+ /**
31
+ * Quote a value so the written line is interpreted IDENTICALLY by a POSIX shell
32
+ * `source` (the assistant entrypoint does `set -a; . user.env`) and by dotenv
33
+ * (akm `env run` / OpenPalm's `parseEnvFile`).
34
+ *
35
+ * The shared `quoteEnvValue` (env.ts) is tuned for dotenv/compose only: it
36
+ * leaves values with internal spaces bare (`OWNER=Ada Lovelace`) and uses
37
+ * double-quote+backslash escaping — both of which a shell `source` mis-parses
38
+ * (word-splitting, `&`/`$` interpretation). POSIX single-quoting is the one
39
+ * encoding both agree on: everything inside `'...'` is literal in shell AND in
40
+ * dotenv. Simple token-shaped values are written bare for readability; anything
41
+ * else is single-quoted, with embedded single quotes closed/escaped/reopened
42
+ * the POSIX way (`'\''`).
43
+ */
44
+ function quoteForUserEnv(value: string): string {
45
+ if (value === "") return "";
46
+ // Bare-safe: characters that need no quoting in either shell or dotenv.
47
+ if (/^[A-Za-z0-9_./:@%+,=-]+$/.test(value)) return value;
48
+ return `'${value.replace(/'/g, `'\\''`)}'`;
49
+ }
50
+
51
+ /** akm ref for the user-managed environment file. */
52
+ export const AKM_USER_ENV_REF = "env:user";
53
+
54
+ const ENV_DIR_MODE = 0o700;
55
+ const ENV_FILE_MODE = 0o600;
56
+
57
+ /**
58
+ * Build the env that points akm at the shared OpenPalm stash. We mirror the
59
+ * layout that the assistant/admin containers use (see
60
+ * `.openpalm/config/stack/core.compose.yml`) so host-side and container-side
61
+ * runs resolve to the same files.
62
+ *
63
+ * Host-side runs use the same explicit directories as the assistant container:
64
+ * config in config/akm, cache in data/akm/cache, and durable data in
65
+ * data/akm/data. Used by automation execution (`executeAutomation`).
66
+ */
67
+ export function buildAkmEnv(state: ControlPlaneState): NodeJS.ProcessEnv {
68
+ return {
69
+ ...process.env,
70
+ AKM_STASH_DIR: state.stashDir,
71
+ AKM_CONFIG_DIR: `${state.configDir}/akm`,
72
+ AKM_CACHE_DIR: `${state.dataDir}/akm/cache`,
73
+ AKM_DATA_DIR: `${state.dataDir}/akm/data`,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Canonical akm `env:user` file path for a control-plane state.
79
+ *
80
+ * Deterministic: akm (>= 0.8.0) materializes env files at
81
+ * `${AKM_STASH_DIR}/env/<name>.env`, and `state.stashDir` is the stash root.
82
+ * Returns the path regardless of whether the file currently exists.
83
+ */
84
+ export function userEnvPathSync(state: ControlPlaneState): string {
85
+ return `${state.stashDir}/env/user.env`;
86
+ }
87
+
88
+ /**
89
+ * Ensure the user env file exists and return its absolute path.
90
+ *
91
+ * Pure filesystem — no akm subprocess. Returns immediately when the file is
92
+ * already provisioned (the steady state — read paths pay no extra syscalls).
93
+ * Otherwise creates `knowledge/env/` (0700) and an empty `user.env` (0600).
94
+ */
95
+ export function ensureAkmUserEnv(state: ControlPlaneState): string {
96
+ const envPath = userEnvPathSync(state);
97
+ if (existsSync(envPath)) return envPath;
98
+
99
+ mkdirSync(dirname(envPath), { recursive: true, mode: ENV_DIR_MODE });
100
+ writeFileSync(envPath, "", { mode: ENV_FILE_MODE });
101
+ chmodSync(envPath, ENV_FILE_MODE);
102
+ return envPath;
103
+ }
104
+
105
+ /**
106
+ * Write a single key/value into the user env file (`env:user`).
107
+ *
108
+ * The value is shell-quoted before it is written so the assistant entrypoint
109
+ * can `source` the file without word-splitting on spaces or special
110
+ * characters. `ensureAkmUserEnv` guarantees the file exists; `chmodSync`
111
+ * keeps it 0600. Throws on filesystem errors so callers can surface the error.
112
+ */
113
+ export function writeUserEnvKey(state: ControlPlaneState, key: string, value: string): void {
114
+ const path = ensureAkmUserEnv(state);
115
+ writeFileSync(path, upsertEnvValue(readFileSync(path, "utf-8"), key, quoteForUserEnv(value)));
116
+ chmodSync(path, ENV_FILE_MODE);
117
+ }
118
+
119
+ /**
120
+ * Remove a key from the user env file (`env:user`). Idempotent: removing an
121
+ * absent key rewrites the file unchanged. Throws on filesystem errors.
122
+ */
123
+ export function deleteUserEnvKey(state: ControlPlaneState, key: string): void {
124
+ const path = ensureAkmUserEnv(state);
125
+ writeFileSync(path, removeEnvKey(readFileSync(path, "utf-8"), key));
126
+ chmodSync(path, ENV_FILE_MODE);
127
+ }
128
+
129
+ /**
130
+ * Read the user-managed env namespace. Returns `{}` when the file does not
131
+ * exist yet. Pure sync — no subprocess.
132
+ */
133
+ export function readUserEnvSync(state: ControlPlaneState): Record<string, string> {
134
+ return readUserEnvFile(userEnvPathSync(state));
135
+ }
136
+
137
+ /**
138
+ * Return the parsed contents of a user env file (public API used by the admin
139
+ * UI list endpoint). `parseEnvFile` returns `{}` for a missing or unreadable
140
+ * file (it backs up corrupt files internally), so no extra guards are needed.
141
+ */
142
+ export function readUserEnvFile(envPath: string): Record<string, string> {
143
+ return parseEnvFile(envPath);
144
+ }
@@ -8,20 +8,29 @@ function timestampDirName(now = new Date()): string {
8
8
  /**
9
9
  * Create a durable backup snapshot of the current OP_HOME contents.
10
10
  *
11
- * The backup is written under OP_HOME/backups/<timestamp>/ and excludes the
12
- * backups directory itself to avoid recursive copies.
11
+ * The backup is written under OP_HOME/data/backups/<timestamp>/ and excludes
12
+ * existing backups to avoid recursive copies.
13
13
  */
14
14
  export function backupOpenPalmHome(homeDir: string): string | null {
15
15
  if (!existsSync(homeDir)) return null;
16
16
 
17
- const backupDir = join(homeDir, "backups", timestampDirName());
17
+ const backupDir = join(homeDir, "data", "backups", timestampDirName());
18
18
  mkdirSync(backupDir, { recursive: true });
19
19
 
20
20
  let copiedAny = false;
21
21
  for (const entry of readdirSync(homeDir, { withFileTypes: true })) {
22
- if (entry.name === "backups") continue;
23
-
24
22
  const sourcePath = join(homeDir, entry.name);
23
+ if (entry.name === "data") {
24
+ const dataTarget = join(backupDir, entry.name);
25
+ mkdirSync(dataTarget, { recursive: true });
26
+ for (const dataEntry of readdirSync(sourcePath, { withFileTypes: true })) {
27
+ if (dataEntry.name === "backups") continue;
28
+ cpSync(join(sourcePath, dataEntry.name), join(dataTarget, dataEntry.name), { recursive: true });
29
+ copiedAny = true;
30
+ }
31
+ continue;
32
+ }
33
+
25
34
  const targetPath = join(backupDir, entry.name);
26
35
  cpSync(sourcePath, targetPath, { recursive: true });
27
36
  copiedAny = true;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Channel validation, discovery, and allowlist checks for the OpenPalm control plane.
3
3
  */
4
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { existsSync, readFileSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
6
  import { parse as yamlParse } from "yaml";
7
7
  import type { ChannelInfo } from "./types.js";
@@ -16,6 +16,43 @@ function isValidChannelName(name: string): boolean {
16
16
  return CHANNEL_NAME_RE.test(name);
17
17
  }
18
18
 
19
+ function addonComposePaths(homeDir: string): string[] {
20
+ const paths: string[] = [];
21
+
22
+ for (const name of ['channels.compose.yml', 'services.compose.yml', 'custom.compose.yml']) {
23
+ const composePath = `${homeDir}/config/stack/${name}`;
24
+ if (existsSync(composePath)) paths.push(composePath);
25
+ }
26
+
27
+ return paths;
28
+ }
29
+
30
+ function channelNamesFromCompose(composePath: string): string[] {
31
+ try {
32
+ const content = readFileSync(composePath, "utf-8");
33
+ const doc = yamlParse(content);
34
+ if (typeof doc !== "object" || doc === null) return [];
35
+ const services = (doc as Record<string, unknown>).services;
36
+ if (typeof services !== "object" || services === null) return [];
37
+
38
+ const names: string[] = [];
39
+ for (const [svcName, svcDef] of Object.entries(services as Record<string, unknown>)) {
40
+ if (typeof svcDef !== "object" || svcDef === null) continue;
41
+ const env = (svcDef as Record<string, unknown>).environment;
42
+ if (typeof env === "object" && env !== null) {
43
+ if (Array.isArray(env)) {
44
+ if (env.some((e: unknown) => typeof e === "string" && e.startsWith("CHANNEL_NAME="))) names.push(svcName);
45
+ } else if ("CHANNEL_NAME" in (env as Record<string, unknown>)) {
46
+ names.push(svcName);
47
+ }
48
+ }
49
+ }
50
+ return names;
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
19
56
  // ── Channel Discovery ─────────────────────────────────────────────────
20
57
 
21
58
  /**
@@ -51,7 +88,8 @@ export function isChannelAddon(composePath: string): boolean {
51
88
  }
52
89
 
53
90
  /**
54
- * Discover installed channels by scanning stack/addons/ for channel addons.
91
+ * Discover installed channels from explicit first-party addon state plus
92
+ * custom stack/addons/ overlays.
55
93
  * A channel addon is identified by compose-derived truth: its compose.yml
56
94
  * defines services with a CHANNEL_NAME environment variable.
57
95
  *
@@ -62,20 +100,8 @@ export function isChannelAddon(composePath: string): boolean {
62
100
  */
63
101
  export function discoverChannels(configDir: string): ChannelInfo[] {
64
102
  const homeDir = dirname(configDir);
65
- const addonsDir = `${homeDir}/config/stack/addons`;
66
- if (!existsSync(addonsDir)) return [];
67
-
68
- const entries = readdirSync(addonsDir, { withFileTypes: true });
69
- return entries
70
- .filter((entry) => {
71
- if (!entry.isDirectory()) return false;
72
- const composePath = `${addonsDir}/${entry.name}/compose.yml`;
73
- return existsSync(composePath) && isChannelAddon(composePath);
74
- })
75
- .map((entry) => ({
76
- name: entry.name,
77
- ymlPath: `${addonsDir}/${entry.name}/compose.yml`,
78
- }))
103
+ return addonComposePaths(homeDir)
104
+ .flatMap((composePath) => channelNamesFromCompose(composePath).map((name) => ({ name, ymlPath: composePath })))
79
105
  .filter((ch) => isValidChannelName(ch.name));
80
106
  }
81
107
 
@@ -84,8 +110,8 @@ export function discoverChannels(configDir: string): ChannelInfo[] {
84
110
  /**
85
111
  * Check if a service name is allowed. Core services are always allowed.
86
112
  * Addon services are allowed if they appear as a compose service defined in
87
- * any addon compose file under stack/addons/. This is compose-derived: the
88
- * actual compose content is checked, not directory naming conventions.
113
+ * any active addon compose file. This is compose-derived: the actual compose
114
+ * content is checked, not directory naming conventions.
89
115
  */
90
116
  export function isAllowedService(value: string, configDir?: string): boolean {
91
117
  if (!value || !value.trim() || value !== value.toLowerCase()) return false;
@@ -93,14 +119,8 @@ export function isAllowedService(value: string, configDir?: string): boolean {
93
119
 
94
120
  if (configDir) {
95
121
  const homeDir = dirname(configDir);
96
- const addonsDir = `${homeDir}/config/stack/addons`;
97
- if (!existsSync(addonsDir)) return false;
98
-
99
- // Check if any addon compose.yml defines this service name (YAML-parsed)
100
- for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
101
- if (!entry.isDirectory()) continue;
102
- const composePath = `${addonsDir}/${entry.name}/compose.yml`;
103
- if (!existsSync(composePath)) continue;
122
+ // Check if any active addon compose.yml defines this service name (YAML-parsed)
123
+ for (const composePath of addonComposePaths(homeDir)) {
104
124
  try {
105
125
  const content = readFileSync(composePath, "utf-8");
106
126
  const doc = yamlParse(content);
@@ -120,14 +140,13 @@ export function isAllowedService(value: string, configDir?: string): boolean {
120
140
 
121
141
  /**
122
142
  * Check if a channel name is valid and installed.
123
- * Accepts any channel with a compose.yml in stack/addons/<name>/.
143
+ * Accepts enabled first-party channels and custom channel overlays.
124
144
  */
125
145
  export function isValidChannel(value: string, configDir?: string): boolean {
126
146
  if (!value || !value.trim()) return false;
127
147
  if (!isValidChannelName(value)) return false;
128
148
  if (configDir) {
129
- const homeDir = dirname(configDir);
130
- return existsSync(`${homeDir}/config/stack/addons/${value}/compose.yml`);
149
+ return discoverChannels(configDir).some((channel) => channel.name === value);
131
150
  }
132
151
  return false;
133
152
  }
@@ -112,7 +112,7 @@ describe("guardrail: compose preflight before mutation", () => {
112
112
  // Verify composePreflight is imported
113
113
  expect(lifecycleTs).toContain("composePreflight");
114
114
  // Verify preflight appears BEFORE snapshot in the source
115
- const preflightIdx = lifecycleTs.indexOf("composePreflight({ files, envFiles })");
115
+ const preflightIdx = lifecycleTs.indexOf("composePreflight({ files, envFiles, profiles })");
116
116
  const snapshotIdx = lifecycleTs.indexOf("snapshotCurrentState(state)");
117
117
  expect(preflightIdx).toBeGreaterThan(0);
118
118
  expect(snapshotIdx).toBeGreaterThan(0);
@@ -2,12 +2,13 @@
2
2
  * Tests for canonical compose argument builder.
3
3
  */
4
4
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
5
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
  import {
9
9
  buildComposeOptions,
10
10
  buildComposeCliArgs,
11
+ writeRunScript,
11
12
  } from "./compose-args.js";
12
13
  import type { ControlPlaneState } from "./types.js";
13
14
 
@@ -18,10 +19,9 @@ function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneStat
18
19
  return {
19
20
  homeDir: tempDir,
20
21
  configDir,
21
- stashDir: join(tempDir, "stash"),
22
+ stashDir: join(tempDir, "knowledge"),
22
23
  workspaceDir: join(tempDir, "workspace"),
23
- cacheDir: join(tempDir, "cache"),
24
- stateDir: join(tempDir, "state"),
24
+ dataDir: join(tempDir, "data"),
25
25
  stackDir: join(configDir, "stack"),
26
26
  services: {},
27
27
  artifacts: { compose: "" },
@@ -36,26 +36,24 @@ function seedCoreCompose(): void {
36
36
  writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
37
37
  }
38
38
 
39
- function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
40
- const stackDir = join(tempDir, "config", "stack");
39
+ function seedEnvFiles(files: { stack?: boolean } = {}): void {
41
40
  if (files.stack) {
42
- mkdirSync(stackDir, { recursive: true });
43
- writeFileSync(join(stackDir, "stack.env"), "KEY=val");
44
- }
45
- if (files.guardian) {
46
- mkdirSync(stackDir, { recursive: true });
47
- writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
41
+ const envDir = join(tempDir, "knowledge", "env");
42
+ mkdirSync(envDir, { recursive: true });
43
+ writeFileSync(join(envDir, "stack.env"), "KEY=val");
48
44
  }
49
45
  }
50
46
 
51
47
  function seedAddon(name: string): void {
52
- const addonDir = join(tempDir, "config", "stack", "addons", name);
53
- mkdirSync(addonDir, { recursive: true });
54
- writeFileSync(join(addonDir, "compose.yml"), "services: {}");
48
+ const stackDir = join(tempDir, "config", "stack");
49
+ mkdirSync(stackDir, { recursive: true });
50
+ writeFileSync(join(stackDir, "channels.compose.yml"), `services:\n ${name}:\n profiles: [\"addon.${name}\"]\n image: test\n`);
51
+ writeFileSync(join(stackDir, "stack.yml"), `version: 2\naddons:\n - ${name}\n`);
55
52
  }
56
53
 
57
54
  beforeEach(() => {
58
55
  tempDir = mkdtempSync(join(tmpdir(), "compose-args-test-"));
56
+ process.env.OP_HOME = tempDir;
59
57
  });
60
58
 
61
59
  afterEach(() => {
@@ -73,27 +71,37 @@ describe("buildComposeOptions", () => {
73
71
  expect(opts.files[0]).toContain("core.compose.yml");
74
72
  });
75
73
 
76
- it("includes addon overlays when compose files are present in stack/addons", () => {
74
+ it("includes fixed channel compose and profile from stack.yml", () => {
77
75
  seedCoreCompose();
78
76
  seedAddon("chat");
79
77
 
80
78
  const state = makeState();
81
79
  const opts = buildComposeOptions(state);
82
80
  expect(opts.files).toHaveLength(2);
83
- expect(opts.files[1]).toContain("chat");
81
+ expect(opts.files[1]).toContain("channels.compose.yml");
82
+ expect(opts.profiles).toContain("addon.chat");
83
+ });
84
+
85
+ it("includes the user custom compose file", () => {
86
+ seedCoreCompose();
87
+ const stackDir = join(tempDir, "config", "stack");
88
+ writeFileSync(join(stackDir, "custom.compose.yml"), "services: {}");
89
+
90
+ const state = makeState();
91
+ const opts = buildComposeOptions(state);
92
+ expect(opts.files).toHaveLength(2);
93
+ expect(opts.files[1]).toContain("custom.compose.yml");
84
94
  });
85
95
 
86
96
  it("returns env files in correct order", () => {
87
- // Note: vault/user/user.env is no longer a
88
- // compose env_file. The runtime env file list is: stack.env, guardian.env.
89
- // Even when a legacy user.env is present on disk, it is intentionally
90
- // excluded from the compose args.
91
- seedEnvFiles({ stack: true, guardian: true });
97
+ // The runtime --env-file list is knowledge/env/stack.env only. The user env
98
+ // (knowledge/env/user.env) is sourced by the assistant entrypoint, not a
99
+ // compose env_file.
100
+ seedEnvFiles({ stack: true });
92
101
  const state = makeState();
93
102
  const opts = buildComposeOptions(state);
94
- expect(opts.envFiles).toHaveLength(2);
103
+ expect(opts.envFiles).toHaveLength(1);
95
104
  expect(opts.envFiles[0]).toContain("stack.env");
96
- expect(opts.envFiles[1]).toContain("guardian.env");
97
105
  });
98
106
 
99
107
  it("excludes missing env files", () => {
@@ -115,6 +123,38 @@ describe("buildComposeCliArgs", () => {
115
123
  expect(args[1]).toBe("openpalm");
116
124
  });
117
125
 
126
+ it("uses OP_PROJECT_NAME from stack.env", () => {
127
+ seedCoreCompose();
128
+ seedEnvFiles({ stack: true });
129
+ writeFileSync(join(tempDir, "knowledge", "env", "stack.env"), "OP_PROJECT_NAME=openpalm-test\n");
130
+ const state = makeState();
131
+ const args = buildComposeCliArgs(state);
132
+ expect(args[0]).toBe("--project-name");
133
+ expect(args[1]).toBe("openpalm-test");
134
+ });
135
+
136
+ it("uses canonical voice and ollama profile ids", () => {
137
+ seedCoreCompose();
138
+ seedEnvFiles({ stack: true });
139
+ writeFileSync(join(tempDir, "knowledge", "env", "stack.env"), "OP_VOICE_PROFILE=addon.voice.cuda\nOP_OLLAMA_PROFILE=addon.ollama.cpu\n");
140
+ const state = makeState();
141
+ const args = buildComposeCliArgs(state);
142
+ expect(args).toContain("addon.voice.cuda");
143
+ expect(args).toContain("addon.ollama.cpu");
144
+ });
145
+
146
+ it("ignores non-canonical addon profile ids", () => {
147
+ seedCoreCompose();
148
+ seedEnvFiles({ stack: true });
149
+ writeFileSync(join(tempDir, "knowledge", "env", "stack.env"), "OP_VOICE_PROFILE=not-canonical\nOP_OLLAMA_PROFILE=also-not-canonical\n");
150
+ const state = makeState();
151
+ const args = buildComposeCliArgs(state);
152
+ expect(args).not.toContain("not-canonical");
153
+ expect(args).not.toContain("also-not-canonical");
154
+ expect(args).not.toContain("addon.voice.cuda");
155
+ expect(args).not.toContain("addon.ollama.cpu");
156
+ });
157
+
118
158
  it("includes -f flags for compose files", () => {
119
159
  seedCoreCompose();
120
160
  const state = makeState();
@@ -125,18 +165,16 @@ describe("buildComposeCliArgs", () => {
125
165
  });
126
166
 
127
167
  it("includes --env-file flags for env files that exist", () => {
128
- // Note: vault/user/user.env is no longer
129
- // listed in the compose env_file set. Only stack.env and guardian.env
130
- // (when present) are passed via --env-file.
168
+ // Only knowledge/env/stack.env is passed via --env-file.
131
169
  seedCoreCompose();
132
- seedEnvFiles({ stack: true, guardian: true });
170
+ seedEnvFiles({ stack: true });
133
171
  const state = makeState();
134
172
  const args = buildComposeCliArgs(state);
135
173
  const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
136
174
  if (arg === "--env-file") acc.push(i);
137
175
  return acc;
138
176
  }, []);
139
- expect(envFileIndices).toHaveLength(2);
177
+ expect(envFileIndices).toHaveLength(1);
140
178
  });
141
179
 
142
180
  it("does not include --env-file for missing files", () => {
@@ -146,7 +184,7 @@ describe("buildComposeCliArgs", () => {
146
184
  expect(args).not.toContain("--env-file");
147
185
  });
148
186
 
149
- it("includes addon overlays in -f flags", () => {
187
+ it("includes fixed channel compose in -f flags", () => {
150
188
  seedCoreCompose();
151
189
  seedAddon("chat");
152
190
 
@@ -157,6 +195,27 @@ describe("buildComposeCliArgs", () => {
157
195
  return acc;
158
196
  }, []);
159
197
  expect(fFlags).toHaveLength(2);
160
- expect(fFlags[1]).toContain("chat");
198
+ expect(fFlags[1]).toContain("channels.compose.yml");
199
+ });
200
+ });
201
+
202
+ // ── writeRunScript ───────────────────────────────────────────────────────
203
+
204
+ describe("writeRunScript", () => {
205
+ it("sources stack.env and writes resolved profile args", () => {
206
+ seedCoreCompose();
207
+ seedEnvFiles({ stack: true });
208
+ const state = makeState();
209
+
210
+ writeRunScript(state);
211
+
212
+ const script = readFileSync(join(tempDir, "run.sh"), "utf-8");
213
+ expect(script).toContain("set -a");
214
+ expect(script).toContain('OP_HOME="${OP_HOME:-$SCRIPT_DIR}"');
215
+ expect(script).toContain('source "${OP_HOME}/knowledge/env/stack.env"');
216
+ expect(script).toContain('profile_args=()');
217
+ expect(script).toContain('docker compose --project-name "${OP_PROJECT_NAME:-${COMPOSE_PROJECT_NAME:-openpalm}}"');
218
+ expect(script).not.toContain('--profile ${OP_VOICE_PROFILE}');
219
+ expect(script).not.toContain('--profile ${OP_OLLAMA_PROFILE}');
161
220
  });
162
221
  });