@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.
- 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 -109
- 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 +42 -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
|
@@ -5,49 +5,159 @@
|
|
|
5
5
|
* construction into a single shared module. Both CLI and admin
|
|
6
6
|
* routes use these functions instead of assembling args inline.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
8
|
+
import { existsSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
9
9
|
import type { ControlPlaneState } from "./types.js";
|
|
10
10
|
import { buildComposeFileList } from "./lifecycle.js";
|
|
11
11
|
import { buildEnvFiles } from "./config-persistence.js";
|
|
12
12
|
import { resolveComposeProjectName } from "./docker.js";
|
|
13
|
+
import { parseEnvFile } from "./env.js";
|
|
14
|
+
import { canonicalAddonProfileSelection } from "./profile-ids.js";
|
|
15
|
+
import { listStackSpecAddons } from "./stack-spec.js";
|
|
13
16
|
|
|
14
17
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
15
18
|
|
|
16
19
|
export type ComposeOptions = {
|
|
17
20
|
files: string[];
|
|
18
21
|
envFiles: string[];
|
|
22
|
+
profiles: string[];
|
|
19
23
|
};
|
|
20
24
|
|
|
25
|
+
// ── Profile Resolution ───────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve active Docker Compose profiles from the stack env.
|
|
29
|
+
* Reads OP_VOICE_PROFILE and OP_OLLAMA_PROFILE (addon hardware profiles).
|
|
30
|
+
* Returns deduplicated, non-empty profile strings.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveActiveProfiles(state: ControlPlaneState): string[] {
|
|
33
|
+
const profiles: string[] = [];
|
|
34
|
+
const stackEnvPath = `${state.stashDir}/env/stack.env`;
|
|
35
|
+
let env: Record<string, string> = {};
|
|
36
|
+
if (existsSync(stackEnvPath)) {
|
|
37
|
+
env = parseEnvFile(stackEnvPath);
|
|
38
|
+
for (const profile of (env.COMPOSE_PROFILES ?? '').split(',')) {
|
|
39
|
+
const trimmed = profile.trim();
|
|
40
|
+
if (trimmed) profiles.push(trimmed);
|
|
41
|
+
}
|
|
42
|
+
const voiceProfile = canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '');
|
|
43
|
+
if (voiceProfile) profiles.push(voiceProfile);
|
|
44
|
+
const ollamaProfile = canonicalAddonProfileSelection('ollama', env.OP_OLLAMA_PROFILE ?? '');
|
|
45
|
+
if (ollamaProfile) profiles.push(ollamaProfile);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const addon of listStackSpecAddons(state.stackDir)) {
|
|
49
|
+
if (addon === 'voice') {
|
|
50
|
+
profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
|
|
51
|
+
} else if (addon === 'ollama') {
|
|
52
|
+
profiles.push(canonicalAddonProfileSelection('ollama', env.OP_OLLAMA_PROFILE ?? '') || 'addon.ollama.cpu');
|
|
53
|
+
} else {
|
|
54
|
+
profiles.push(`addon.${addon}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...new Set(profiles)];
|
|
59
|
+
}
|
|
60
|
+
|
|
21
61
|
// ── Builders ─────────────────────────────────────────────────────────────
|
|
22
62
|
|
|
23
63
|
/**
|
|
24
|
-
* Build the compose file
|
|
25
|
-
* Returns the resolved
|
|
26
|
-
*
|
|
27
|
-
* Note: env files are already filtered to only existing paths by
|
|
28
|
-
* `buildEnvFiles()` in config-persistence.ts.
|
|
64
|
+
* Build the compose file, env file, and profile lists for a given state.
|
|
65
|
+
* Returns the resolved values for use with docker.ts functions.
|
|
29
66
|
*/
|
|
30
67
|
export function buildComposeOptions(state: ControlPlaneState): ComposeOptions {
|
|
31
68
|
return {
|
|
32
69
|
files: buildComposeFileList(state),
|
|
33
70
|
envFiles: buildEnvFiles(state),
|
|
71
|
+
profiles: resolveActiveProfiles(state),
|
|
34
72
|
};
|
|
35
73
|
}
|
|
36
74
|
|
|
37
75
|
/**
|
|
38
76
|
* Build the full docker compose CLI argument array for a given state.
|
|
39
77
|
*
|
|
40
|
-
* Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, ...]
|
|
78
|
+
* Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, '--profile', addon.voice.cpu, ...]
|
|
41
79
|
*
|
|
42
80
|
* Only includes env files that exist on disk.
|
|
43
81
|
*/
|
|
44
82
|
export function buildComposeCliArgs(state: ControlPlaneState): string[] {
|
|
45
|
-
const { files, envFiles } = buildComposeOptions(state);
|
|
83
|
+
const { files, envFiles, profiles } = buildComposeOptions(state);
|
|
46
84
|
|
|
47
85
|
return [
|
|
48
86
|
"--project-name",
|
|
49
|
-
resolveComposeProjectName(),
|
|
87
|
+
resolveComposeProjectName(collectEnvOverrides(envFiles)),
|
|
50
88
|
...files.flatMap((f) => ["-f", f]),
|
|
51
89
|
...envFiles.filter((f) => existsSync(f)).flatMap((f) => ["--env-file", f]),
|
|
90
|
+
...profiles.flatMap((p) => ["--profile", p]),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Run Script ───────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Convert an absolute path to a ${OP_HOME}-relative shell expression.
|
|
98
|
+
* E.g. "/home/user/.openpalm/config/stack/core.compose.yml"
|
|
99
|
+
* → "${OP_HOME}/config/stack/core.compose.yml"
|
|
100
|
+
*/
|
|
101
|
+
function toOpHomeRelative(absPath: string, homeDir: string): string {
|
|
102
|
+
if (absPath.startsWith(homeDir)) {
|
|
103
|
+
return absPath.replace(homeDir, "${OP_HOME}");
|
|
104
|
+
}
|
|
105
|
+
return absPath;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function shellArg(value: string): string {
|
|
109
|
+
if (value.includes("${")) return `"${value}"`;
|
|
110
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collectEnvOverrides(envFiles: string[]): Record<string, string> {
|
|
114
|
+
const overrides: Record<string, string> = {};
|
|
115
|
+
for (const envFile of envFiles) {
|
|
116
|
+
Object.assign(overrides, parseEnvFile(envFile));
|
|
117
|
+
}
|
|
118
|
+
return overrides;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write the effective docker compose command to OP_HOME/run.sh.
|
|
123
|
+
* Uses environment variable references (${OP_HOME}, ${OP_PROJECT_NAME}) so
|
|
124
|
+
* the script is portable and self-documenting.
|
|
125
|
+
*
|
|
126
|
+
* Wraps `source stack.env` in `set -a` / `set +a` so variables without
|
|
127
|
+
* explicit `export` are still available to child processes (docker compose).
|
|
128
|
+
*
|
|
129
|
+
* Called after any stack modification: setup, addon enable/disable,
|
|
130
|
+
* profile change, or upgrade.
|
|
131
|
+
*/
|
|
132
|
+
export function writeRunScript(state: ControlPlaneState): void {
|
|
133
|
+
const { files, envFiles, profiles } = buildComposeOptions(state);
|
|
134
|
+
const stackEnvRef = toOpHomeRelative(`${state.stashDir}/env/stack.env`, state.homeDir);
|
|
135
|
+
|
|
136
|
+
const lines: string[] = [
|
|
137
|
+
"#!/usr/bin/env bash",
|
|
138
|
+
"# Auto-generated by OpenPalm - DO NOT EDIT",
|
|
139
|
+
"# This file reproduces the exact docker compose invocation",
|
|
140
|
+
"# used by the control plane. Run it to start/restart the stack.",
|
|
141
|
+
"",
|
|
142
|
+
`set -euo pipefail`,
|
|
143
|
+
`SCRIPT_DIR="$(cd -- "$(dirname -- "${"${BASH_SOURCE[0]}"}")" && pwd)"`,
|
|
144
|
+
`OP_HOME="${"${OP_HOME:-$SCRIPT_DIR}"}"`,
|
|
145
|
+
`export OP_HOME`,
|
|
146
|
+
`set -a`,
|
|
147
|
+
`source ${shellArg(stackEnvRef)}`,
|
|
148
|
+
`set +a`,
|
|
149
|
+
`profile_args=(${profiles.map(shellArg).join(' ')})`,
|
|
150
|
+
`docker compose --project-name "${"${OP_PROJECT_NAME:-${COMPOSE_PROJECT_NAME:-openpalm}}"}" \\`,
|
|
151
|
+
` "${"${profile_args[@]}"}" \\`,
|
|
152
|
+
...files.flatMap((f) => [` -f ${shellArg(toOpHomeRelative(f, state.homeDir))} \\`]),
|
|
153
|
+
...envFiles.filter((f) => existsSync(f)).flatMap((f) => [` --env-file ${shellArg(toOpHomeRelative(f, state.homeDir))} \\`]),
|
|
154
|
+
` up -d`,
|
|
155
|
+
"",
|
|
52
156
|
];
|
|
157
|
+
|
|
158
|
+
const content = lines.join("\n");
|
|
159
|
+
const runScriptPath = `${state.homeDir}/run.sh`;
|
|
160
|
+
mkdirSync(state.homeDir, { recursive: true });
|
|
161
|
+
writeFileSync(runScriptPath, content, { mode: 0o755 });
|
|
162
|
+
chmodSync(runScriptPath, 0o755);
|
|
53
163
|
}
|
|
@@ -3,58 +3,52 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Writes and derives live runtime files (compose, env, schemas).
|
|
5
5
|
* Files are validated in-place before writing; rollback is handled by
|
|
6
|
-
* the rollback module (snapshot to
|
|
6
|
+
* the rollback module (snapshot to OP_HOME/data/rollback/).
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync,
|
|
9
|
-
import { dirname } from "node:path";
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
10
10
|
import { parse as yamlParse } from "yaml";
|
|
11
|
-
import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
11
|
+
import { parseEnvContent, parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
12
|
+
import { assertNoSecretLikeStackEnvKeys, isSecretLikeStackEnvKey } from './secrets.js';
|
|
13
|
+
import { ensureSecret } from './secrets-files.js';
|
|
12
14
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
13
|
-
import { isChannelAddon } from "./channels.js";
|
|
14
15
|
import { listEnabledAddonIds } from "./registry.js";
|
|
15
16
|
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
16
17
|
|
|
17
18
|
import {
|
|
18
19
|
readCoreCompose,
|
|
20
|
+
readBundledStackAsset,
|
|
19
21
|
} from "./core-assets.js";
|
|
20
22
|
export { sha256, randomHex } from "./crypto.js";
|
|
21
23
|
import { sha256, randomHex } from "./crypto.js";
|
|
22
24
|
|
|
23
|
-
const DEFAULT_IMAGE_TAG =
|
|
25
|
+
const DEFAULT_IMAGE_TAG = "latest";
|
|
24
26
|
|
|
25
27
|
// ── Env File Management ──────────────────────────────────────────────
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* Return the env files used for docker compose --env-file args.
|
|
29
|
-
* These are the live vault env files.
|
|
30
31
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `vault:user` store and are sourced by the assistant entrypoint at
|
|
36
|
-
* container startup. The legacy file is migrated into akm and deleted
|
|
37
|
-
* on upgrade; subsequent `docker compose` invocations must not reference
|
|
38
|
-
* it (compose interpolates `${VAR}` against the merged --env-file
|
|
39
|
-
* contents, and a stale user.env would shadow the akm-sourced values).
|
|
32
|
+
* Only `knowledge/env/stack.env` (non-secret system config). Secret values
|
|
33
|
+
* live in `knowledge/secrets/<ENV_KEY>` and are granted to services as Compose
|
|
34
|
+
* file secrets. The user env (`knowledge/env/user.env`) is NOT a compose
|
|
35
|
+
* env_file — it is sourced by the assistant entrypoint at container startup.
|
|
40
36
|
*/
|
|
41
37
|
export function buildEnvFiles(state: ControlPlaneState): string[] {
|
|
42
38
|
return [
|
|
43
|
-
`${state.
|
|
44
|
-
`${state.stackDir}/guardian.env`,
|
|
39
|
+
`${state.stashDir}/env/stack.env`,
|
|
45
40
|
].filter(existsSync);
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
/**
|
|
49
|
-
* Write system-managed values to
|
|
44
|
+
* Write system-managed values to knowledge/env/stack.env.
|
|
50
45
|
*
|
|
51
|
-
*
|
|
52
|
-
* Use
|
|
46
|
+
* Secret-like keys are NOT written here — they belong in knowledge/secrets/.
|
|
47
|
+
* Use ensureChannelSecret() for channel secrets.
|
|
53
48
|
*/
|
|
54
49
|
export function writeSystemEnv(state: ControlPlaneState): void {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
50
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
51
|
+
mkdirSync(`${state.stashDir}/env`, { recursive: true, mode: 0o700 });
|
|
58
52
|
|
|
59
53
|
let base = "";
|
|
60
54
|
if (existsSync(systemEnvPath)) {
|
|
@@ -63,11 +57,12 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
63
57
|
base = generateFallbackSystemEnv(state);
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
// Preserve existing OP_SETUP_COMPLETE
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
// Preserve the existing OP_SETUP_COMPLETE flag as-is.
|
|
61
|
+
// Only the wizard completion path (buildSystemSecretsFromSetup) writes "true".
|
|
62
|
+
// Defaulting to "false" here ensures a fresh install always shows the wizard.
|
|
63
|
+
const parsed = parseEnvFile(systemEnvPath);
|
|
69
64
|
const adminManaged: Record<string, string> = {
|
|
70
|
-
OP_SETUP_COMPLETE:
|
|
65
|
+
OP_SETUP_COMPLETE: parsed.OP_SETUP_COMPLETE === "true" ? "true" : "false",
|
|
71
66
|
};
|
|
72
67
|
|
|
73
68
|
// Backfill OP_UID/OP_GID when the existing stack.env was written by an
|
|
@@ -76,13 +71,20 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
76
71
|
// missing or zero — an operator who manually set OP_UID=2000 (e.g.
|
|
77
72
|
// because they're running on a host with a non-1000 service account)
|
|
78
73
|
// must not be silently changed.
|
|
79
|
-
const parsed = parseEnvFile(systemEnvPath);
|
|
80
74
|
const ids = resolveOperatorIds(state.homeDir);
|
|
81
75
|
if (ids) {
|
|
82
76
|
if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
|
|
83
77
|
if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
|
|
84
78
|
}
|
|
85
79
|
|
|
80
|
+
// Backfill OP_HOME when missing — compose files reference ${OP_HOME}
|
|
81
|
+
// for all volume mounts. Without this, Docker Compose defaults to blank.
|
|
82
|
+
if (!parsed.OP_HOME) adminManaged.OP_HOME = state.homeDir;
|
|
83
|
+
|
|
84
|
+
base = stripSecretLikeEnvKeys(base);
|
|
85
|
+
assertNoSecretLikeStackEnvKeys(parseEnvContent(base));
|
|
86
|
+
assertNoSecretLikeStackEnvKeys(adminManaged);
|
|
87
|
+
|
|
86
88
|
const content = mergeEnvContent(base, adminManaged, {
|
|
87
89
|
sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
|
|
88
90
|
});
|
|
@@ -91,6 +93,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
91
93
|
chmodSync(systemEnvPath, 0o600);
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function stripSecretLikeEnvKeys(content: string): string {
|
|
97
|
+
return content
|
|
98
|
+
.split('\n')
|
|
99
|
+
.filter((line) => {
|
|
100
|
+
let trimmed = line.trim();
|
|
101
|
+
if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7).trimStart();
|
|
102
|
+
const eq = trimmed.indexOf('=');
|
|
103
|
+
if (eq <= 0) return true;
|
|
104
|
+
return !isSecretLikeStackEnvKey(trimmed.slice(0, eq).trim());
|
|
105
|
+
})
|
|
106
|
+
.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
95
110
|
// Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
|
|
96
111
|
// Skipped on Windows where containers run in WSL2 and OP_UID has no
|
|
@@ -104,12 +119,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
104
119
|
"# OpenPalm — System Configuration (managed by CLI/admin)",
|
|
105
120
|
"# Auto-generated fallback.",
|
|
106
121
|
"",
|
|
107
|
-
"# ── Authentication ──────────────────────────────────────────────────",
|
|
108
|
-
`OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
|
|
109
|
-
"",
|
|
110
|
-
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
111
|
-
"OP_OPENCODE_PASSWORD=",
|
|
112
|
-
"",
|
|
113
122
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
114
123
|
`OP_HOME=${state.homeDir}`,
|
|
115
124
|
...idLines,
|
|
@@ -131,37 +140,20 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
131
140
|
// ── Stack Overlay Discovery ────────────────────────────────────────────
|
|
132
141
|
|
|
133
142
|
/**
|
|
134
|
-
* Discover compose overlays
|
|
135
|
-
* Returns
|
|
143
|
+
* Discover active compose overlays.
|
|
144
|
+
* Returns the fixed compose stack: core, services, channels, and custom.
|
|
145
|
+
* First-party services are profile-gated inside services.compose.yml and
|
|
146
|
+
* channels.compose.yml.
|
|
136
147
|
*/
|
|
137
|
-
export function discoverStackOverlays(stackDir: string): string[] {
|
|
148
|
+
export function discoverStackOverlays(stackDir: string, _homeDir?: string): string[] {
|
|
138
149
|
const files: string[] = [];
|
|
139
150
|
|
|
140
151
|
const coreYml = `${stackDir}/core.compose.yml`;
|
|
141
152
|
if (existsSync(coreYml)) files.push(coreYml);
|
|
142
153
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
.filter((e) => e.isDirectory())
|
|
147
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
148
|
-
for (const entry of entries) {
|
|
149
|
-
const dir = `${addonsDir}/${entry.name}`;
|
|
150
|
-
// Pick up compose.yml plus any compose.<variant>.yml sibling
|
|
151
|
-
// overlays (e.g. compose.cdi.yml generated by /admin/voice on
|
|
152
|
-
// CDI hosts). Stable sort: compose.yml first, then siblings
|
|
153
|
-
// alphabetically, so the base file's keys are the defaults and
|
|
154
|
-
// overlays merge on top in deterministic order.
|
|
155
|
-
const overlays = readdirSync(dir, { withFileTypes: true })
|
|
156
|
-
.filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
|
|
157
|
-
.map((e) => e.name)
|
|
158
|
-
.sort((a, b) => {
|
|
159
|
-
if (a === "compose.yml" || a === "compose.yaml") return -1;
|
|
160
|
-
if (b === "compose.yml" || b === "compose.yaml") return 1;
|
|
161
|
-
return a.localeCompare(b);
|
|
162
|
-
});
|
|
163
|
-
for (const name of overlays) files.push(`${dir}/${name}`);
|
|
164
|
-
}
|
|
154
|
+
for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
|
|
155
|
+
const composePath = `${stackDir}/${name}`;
|
|
156
|
+
if (existsSync(composePath)) files.push(composePath);
|
|
165
157
|
}
|
|
166
158
|
|
|
167
159
|
return files;
|
|
@@ -192,79 +184,38 @@ export function buildRuntimeFileMeta(artifacts: {
|
|
|
192
184
|
}
|
|
193
185
|
|
|
194
186
|
// ── Channel Secrets ────────────────────────────────────────────────────
|
|
195
|
-
// Channel HMAC secrets live exclusively in vault/stack/guardian.env.
|
|
196
|
-
|
|
197
|
-
const CHANNEL_SECRET_RE = /^CHANNEL_([A-Z0-9_]+)_SECRET$/;
|
|
198
187
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const result: Record<string, string> = {};
|
|
202
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
203
|
-
const match = key.match(CHANNEL_SECRET_RE);
|
|
204
|
-
if (match?.[1] && value) result[match[1].toLowerCase()] = value;
|
|
205
|
-
}
|
|
206
|
-
return result;
|
|
188
|
+
export function channelSecretName(addon: string): string {
|
|
189
|
+
return `channel_${addon.replace(/-/g, '_')}_secret`;
|
|
207
190
|
}
|
|
208
191
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*/
|
|
212
|
-
export function readChannelSecrets(stackDir: string): Record<string, string> {
|
|
213
|
-
return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Write channel HMAC secrets to state/guardian.env.
|
|
218
|
-
* Merges with existing content; does not overwrite unrelated entries.
|
|
219
|
-
*/
|
|
220
|
-
export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
|
|
221
|
-
const guardianPath = `${stackDir}/guardian.env`;
|
|
222
|
-
mkdirSync(stackDir, { recursive: true });
|
|
223
|
-
|
|
224
|
-
let base = "";
|
|
225
|
-
if (existsSync(guardianPath)) {
|
|
226
|
-
base = readFileSync(guardianPath, "utf-8");
|
|
227
|
-
} else {
|
|
228
|
-
base = "# Guardian channel HMAC secrets — managed by openpalm\n";
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const updates: Record<string, string> = {};
|
|
232
|
-
for (const [ch, secret] of Object.entries(secrets)) {
|
|
233
|
-
updates[`CHANNEL_${ch.toUpperCase()}_SECRET`] = secret;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const content = mergeEnvContent(base, updates);
|
|
237
|
-
writeFileSync(guardianPath, content, { mode: 0o600 });
|
|
238
|
-
// Ensure correct permissions even if file already existed with wrong mode
|
|
239
|
-
chmodSync(guardianPath, 0o600);
|
|
192
|
+
export function ensureChannelSecret(stackDir: string, addon: string): string {
|
|
193
|
+
return ensureSecret(stackDir, channelSecretName(addon), () => randomHex(16));
|
|
240
194
|
}
|
|
241
195
|
|
|
242
196
|
// ── Volume Mount Targets ───────────────────────────────────────────────
|
|
243
197
|
|
|
244
198
|
/**
|
|
245
|
-
* Parse
|
|
246
|
-
*
|
|
247
|
-
* them as root-owned, which causes EACCES inside non-root
|
|
248
|
-
*
|
|
249
|
-
* For file mounts (basename contains a `.`), creates an empty file.
|
|
250
|
-
* For directory mounts (basename has no `.`), creates the directory.
|
|
251
|
-
*
|
|
252
|
-
* Heuristic: a basename containing a `.` is treated as a file. This
|
|
253
|
-
* intentionally includes leading-dot files (e.g. `.env`) because Docker
|
|
254
|
-
* bind mounts to them must be regular files. Bare directory names like
|
|
255
|
-
* `stack` or `addons` lack extensions and are created as directories.
|
|
199
|
+
* Parse enabled compose files and pre-create host-side volume mount
|
|
200
|
+
* targets under OP_HOME as the current user. This prevents Docker from
|
|
201
|
+
* creating them as root-owned, which causes EACCES inside non-root
|
|
202
|
+
* containers.
|
|
256
203
|
*
|
|
257
204
|
* Only mount sources under `state.homeDir` are touched; external paths
|
|
258
205
|
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
206
|
+
*
|
|
207
|
+
* The file-vs-directory distinction is best-effort and only applies to
|
|
208
|
+
* explicit OP_HOME paths.
|
|
259
209
|
*/
|
|
260
210
|
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
261
|
-
const composeFiles = discoverStackOverlays(state.stackDir);
|
|
211
|
+
const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
|
|
262
212
|
if (composeFiles.length === 0) return;
|
|
263
213
|
|
|
264
214
|
const envVars: Record<string, string> = {
|
|
265
215
|
...(process.env as Record<string, string>),
|
|
266
|
-
...parseEnvFile(`${state.
|
|
216
|
+
...parseEnvFile(`${state.stashDir}/env/stack.env`),
|
|
267
217
|
};
|
|
218
|
+
const homeRoot = resolvePath(state.homeDir);
|
|
268
219
|
|
|
269
220
|
for (const file of composeFiles) {
|
|
270
221
|
let doc: Record<string, unknown>;
|
|
@@ -291,18 +242,20 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
|
291
242
|
|
|
292
243
|
const hostPath = expandEnvVars(rawSource, envVars);
|
|
293
244
|
if (!hostPath || !hostPath.startsWith('/')) continue;
|
|
294
|
-
|
|
245
|
+
const resolvedHostPath = resolvePath(hostPath);
|
|
246
|
+
if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue;
|
|
247
|
+
if (existsSync(resolvedHostPath)) continue;
|
|
295
248
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
const basename =
|
|
249
|
+
// Only create mounts under OP_HOME. For now, treat existing explicit
|
|
250
|
+
// file paths as files and directory paths as directories.
|
|
251
|
+
const basename = resolvedHostPath.split('/').pop() ?? '';
|
|
299
252
|
const isFile = basename.includes('.');
|
|
300
253
|
|
|
301
254
|
if (isFile) {
|
|
302
|
-
mkdirSync(dirname(
|
|
303
|
-
writeFileSync(
|
|
255
|
+
mkdirSync(dirname(resolvedHostPath), { recursive: true });
|
|
256
|
+
writeFileSync(resolvedHostPath, '');
|
|
304
257
|
} else {
|
|
305
|
-
mkdirSync(
|
|
258
|
+
mkdirSync(resolvedHostPath, { recursive: true });
|
|
306
259
|
}
|
|
307
260
|
}
|
|
308
261
|
}
|
|
@@ -322,24 +275,25 @@ export function writeRuntimeFiles(
|
|
|
322
275
|
writeFileSync(composePath, state.artifacts.compose);
|
|
323
276
|
}
|
|
324
277
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
278
|
+
for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
|
|
279
|
+
const path = `${state.stackDir}/${name}`;
|
|
280
|
+
if (!existsSync(path)) writeFileSync(path, readBundledStackAsset(name));
|
|
281
|
+
}
|
|
282
|
+
|
|
328
283
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
284
|
+
if (['api', 'chat', 'discord', 'slack'].includes(addon)) {
|
|
285
|
+
for (const channel of ['api', 'chat', 'discord', 'slack']) {
|
|
286
|
+
ensureChannelSecret(state.stackDir, channel);
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
332
289
|
}
|
|
333
290
|
}
|
|
334
291
|
|
|
335
|
-
// Write
|
|
336
|
-
writeChannelSecrets(state.stackDir, channelSecrets);
|
|
337
|
-
|
|
338
|
-
// Write system.env (no channel secrets — those live in guardian.env)
|
|
292
|
+
// Write stack.env (no secrets — those live in knowledge/secrets/)
|
|
339
293
|
writeSystemEnv(state);
|
|
340
294
|
|
|
341
295
|
// Ensure state directory exists
|
|
342
|
-
mkdirSync(state.
|
|
296
|
+
mkdirSync(state.dataDir, { recursive: true });
|
|
343
297
|
|
|
344
298
|
state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
|
|
345
299
|
}
|
|
@@ -11,21 +11,21 @@ describe("seedStashAssets", () => {
|
|
|
11
11
|
beforeEach(() => {
|
|
12
12
|
homeDir = mkdtempSync(join(tmpdir(), "stash-seed-test-"));
|
|
13
13
|
process.env.OP_HOME = homeDir;
|
|
14
|
-
mkdirSync(join(homeDir, "
|
|
14
|
+
mkdirSync(join(homeDir, "knowledge"), { recursive: true });
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
afterEach(() => {
|
|
18
18
|
process.env.OP_HOME = originalHome;
|
|
19
19
|
// Restore writable mode in case a test chmod'd the stash dir.
|
|
20
20
|
try {
|
|
21
|
-
chmodSync(join(homeDir, "
|
|
21
|
+
chmodSync(join(homeDir, "knowledge"), 0o755);
|
|
22
22
|
} catch {
|
|
23
23
|
// ignore — dir may not exist
|
|
24
24
|
}
|
|
25
25
|
rmSync(homeDir, { recursive: true, force: true });
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
it("writes every seed under
|
|
28
|
+
it("writes every seed under knowledge/ on first run", () => {
|
|
29
29
|
const seeds = {
|
|
30
30
|
"skills/test-skill/SKILL.md": "---\nname: test-skill\ntype: skill\n---\nhello\n",
|
|
31
31
|
"commands/test-cmd.md": "---\nname: test-cmd\ntype: command\n---\nrun me\n",
|
|
@@ -34,7 +34,7 @@ describe("seedStashAssets", () => {
|
|
|
34
34
|
|
|
35
35
|
expect(written.sort()).toEqual(Object.keys(seeds).sort());
|
|
36
36
|
for (const [rel, content] of Object.entries(seeds)) {
|
|
37
|
-
const target = join(homeDir, "
|
|
37
|
+
const target = join(homeDir, "knowledge", rel);
|
|
38
38
|
expect(existsSync(target)).toBe(true);
|
|
39
39
|
expect(readFileSync(target, "utf-8")).toBe(content);
|
|
40
40
|
}
|
|
@@ -46,7 +46,7 @@ describe("seedStashAssets", () => {
|
|
|
46
46
|
|
|
47
47
|
// Simulate a previous install: seed first.
|
|
48
48
|
seedStashAssets(seeds);
|
|
49
|
-
const target = join(homeDir, "
|
|
49
|
+
const target = join(homeDir, "knowledge/skills/keep-mine/SKILL.md");
|
|
50
50
|
expect(readFileSync(target, "utf-8")).toBe("ORIGINAL SEED\n");
|
|
51
51
|
|
|
52
52
|
// User edits the file.
|
|
@@ -58,10 +58,10 @@ describe("seedStashAssets", () => {
|
|
|
58
58
|
expect(readFileSync(target, "utf-8")).toBe(userEdit);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
it("creates nested directories under
|
|
61
|
+
it("creates nested directories under knowledge/ as needed", () => {
|
|
62
62
|
const seeds = { "skills/deep/nested/asset/SKILL.md": "x" };
|
|
63
63
|
seedStashAssets(seeds);
|
|
64
|
-
expect(existsSync(join(homeDir, "
|
|
64
|
+
expect(existsSync(join(homeDir, "knowledge/skills/deep/nested/asset/SKILL.md"))).toBe(true);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it("returns an empty list when called with no seeds", () => {
|
|
@@ -70,7 +70,7 @@ describe("seedStashAssets", () => {
|
|
|
70
70
|
|
|
71
71
|
it("rejects seed keys that escape the stash directory", () => {
|
|
72
72
|
// Path-traversal guard: ../ sequences in keys must throw rather than
|
|
73
|
-
// silently writing outside
|
|
73
|
+
// silently writing outside knowledge/.
|
|
74
74
|
expect(() =>
|
|
75
75
|
seedStashAssets({ "../../etc/cron.d/evil": "owned\n" }),
|
|
76
76
|
).toThrow(/escapes stash dir/);
|
|
@@ -91,7 +91,7 @@ describe("seedStashAssets", () => {
|
|
|
91
91
|
const uid = process.getuid?.();
|
|
92
92
|
if (uid === 0) return;
|
|
93
93
|
|
|
94
|
-
const stashDir = join(homeDir, "
|
|
94
|
+
const stashDir = join(homeDir, "knowledge");
|
|
95
95
|
chmodSync(stashDir, 0o555);
|
|
96
96
|
try {
|
|
97
97
|
expect(() =>
|