@openpalm/lib 0.11.0-beta.10 → 0.11.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +97 -88
- package/src/control-plane/registry.ts +142 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +60 -0
- package/src/control-plane/secrets-files.ts +66 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +60 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
package/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, `
|
|
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
|
@@ -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
|
|
12
|
-
* backups
|
|
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,
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
88
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
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, "
|
|
22
|
+
stashDir: join(tempDir, "knowledge"),
|
|
22
23
|
workspaceDir: join(tempDir, "workspace"),
|
|
23
|
-
|
|
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
|
|
40
|
-
const stackDir = join(tempDir, "config", "stack");
|
|
39
|
+
function seedEnvFiles(files: { stack?: boolean } = {}): void {
|
|
41
40
|
if (files.stack) {
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
53
|
-
mkdirSync(
|
|
54
|
-
writeFileSync(join(
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
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("
|
|
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
|
});
|