@openpalm/lib 0.10.2 → 0.11.0-beta.10
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 +4 -2
- package/package.json +11 -3
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +311 -0
- package/src/control-plane/channels.ts +11 -9
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -33
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +148 -73
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +111 -58
- package/src/control-plane/docker.ts +70 -25
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +84 -1
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +190 -292
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +65 -75
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +80 -0
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +247 -51
- package/src/control-plane/registry.ts +404 -54
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +97 -55
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +143 -244
- package/src/control-plane/setup.ts +216 -133
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +75 -60
- package/src/control-plane/spec-to-env.ts +68 -153
- package/src/control-plane/stack-spec.test.ts +22 -84
- package/src/control-plane/stack-spec.ts +9 -89
- package/src/control-plane/types.ts +9 -29
- package/src/control-plane/ui-assets.ts +385 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +102 -56
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/redact-schema.ts +0 -50
- package/src/control-plane/secret-backend.test.ts +0 -359
- package/src/control-plane/secret-backend.ts +0 -322
- package/src/control-plane/spec-validator.ts +0 -159
|
@@ -6,67 +6,55 @@
|
|
|
6
6
|
* the rollback module (snapshot to ~/.cache/openpalm/rollback/).
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
|
|
9
|
-
import {
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { parse as yamlParse } from "yaml";
|
|
11
|
+
import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
10
12
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
11
13
|
import { isChannelAddon } from "./channels.js";
|
|
12
|
-
import { readStackSpec } from "./stack-spec.js";
|
|
13
|
-
import { writeCapabilityVars } from "./spec-to-env.js";
|
|
14
14
|
import { listEnabledAddonIds } from "./registry.js";
|
|
15
|
+
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
15
16
|
|
|
16
|
-
import { generateRedactSchema } from "./redact-schema.js";
|
|
17
|
-
import { readStackEnv } from "./secrets.js";
|
|
18
17
|
import {
|
|
19
18
|
readCoreCompose,
|
|
20
|
-
ensureUserEnvSchema,
|
|
21
|
-
ensureSystemEnvSchema,
|
|
22
19
|
} from "./core-assets.js";
|
|
23
20
|
export { sha256, randomHex } from "./crypto.js";
|
|
24
21
|
import { sha256, randomHex } from "./crypto.js";
|
|
25
22
|
|
|
26
23
|
const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
|
|
27
24
|
|
|
28
|
-
// ── Stack Config (stack.yml) ─────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Check whether Ollama is enabled via active stack/addons/ overlay.
|
|
32
|
-
*/
|
|
33
|
-
export function isOllamaEnabled(state: ControlPlaneState): boolean {
|
|
34
|
-
return listEnabledAddonIds(state.homeDir).includes("ollama");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Check whether admin is enabled via active stack/addons/ overlay.
|
|
39
|
-
*/
|
|
40
|
-
export function isAdminEnabled(state: ControlPlaneState): boolean {
|
|
41
|
-
return listEnabledAddonIds(state.homeDir).includes("admin");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
25
|
// ── Env File Management ──────────────────────────────────────────────
|
|
45
26
|
|
|
46
27
|
/**
|
|
47
28
|
* Return the env files used for docker compose --env-file args.
|
|
48
29
|
* These are the live vault env files.
|
|
49
30
|
*
|
|
50
|
-
* Order: stack.env ->
|
|
31
|
+
* Order: stack.env -> guardian.env
|
|
32
|
+
*
|
|
33
|
+
* Note: `vault/user/user.env` is no longer a
|
|
34
|
+
* compose env_file. User-managed env secrets live in the akm
|
|
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).
|
|
51
40
|
*/
|
|
52
41
|
export function buildEnvFiles(state: ControlPlaneState): string[] {
|
|
53
42
|
return [
|
|
54
|
-
`${state.
|
|
55
|
-
`${state.
|
|
56
|
-
`${state.vaultDir}/stack/guardian.env`,
|
|
43
|
+
`${state.stackDir}/stack.env`,
|
|
44
|
+
`${state.stackDir}/guardian.env`,
|
|
57
45
|
].filter(existsSync);
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
/**
|
|
61
|
-
* Write system-managed values to
|
|
49
|
+
* Write system-managed values to config/stack/stack.env.
|
|
62
50
|
*
|
|
63
51
|
* Channel HMAC secrets are NOT written here — they belong in guardian.env.
|
|
64
52
|
* Use writeChannelSecrets() for channel secrets.
|
|
65
53
|
*/
|
|
66
54
|
export function writeSystemEnv(state: ControlPlaneState): void {
|
|
67
|
-
mkdirSync(
|
|
55
|
+
mkdirSync(state.stackDir, { recursive: true });
|
|
68
56
|
|
|
69
|
-
const systemEnvPath = `${state.
|
|
57
|
+
const systemEnvPath = `${state.stackDir}/stack.env`;
|
|
70
58
|
|
|
71
59
|
let base = "";
|
|
72
60
|
if (existsSync(systemEnvPath)) {
|
|
@@ -82,45 +70,60 @@ export function writeSystemEnv(state: ControlPlaneState): void {
|
|
|
82
70
|
OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
|
|
83
71
|
};
|
|
84
72
|
|
|
73
|
+
// Backfill OP_UID/OP_GID when the existing stack.env was written by an
|
|
74
|
+
// older code path that hard-coded 1000, or when the file was created
|
|
75
|
+
// with missing/zero values. We only override when the current value is
|
|
76
|
+
// missing or zero — an operator who manually set OP_UID=2000 (e.g.
|
|
77
|
+
// because they're running on a host with a non-1000 service account)
|
|
78
|
+
// must not be silently changed.
|
|
79
|
+
const parsed = parseEnvFile(systemEnvPath);
|
|
80
|
+
const ids = resolveOperatorIds(state.homeDir);
|
|
81
|
+
if (ids) {
|
|
82
|
+
if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
|
|
83
|
+
if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
|
|
84
|
+
}
|
|
85
|
+
|
|
85
86
|
const content = mergeEnvContent(base, adminManaged, {
|
|
86
87
|
sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
|
|
87
88
|
});
|
|
88
89
|
|
|
89
|
-
writeFileSync(systemEnvPath, content);
|
|
90
|
+
writeFileSync(systemEnvPath, content, { mode: 0o600 });
|
|
91
|
+
chmodSync(systemEnvPath, 0o600);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
// Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
|
|
96
|
+
// Skipped on Windows where containers run in WSL2 and OP_UID has no
|
|
97
|
+
// meaning on the host process.
|
|
98
|
+
const ids = resolveOperatorIds(state.homeDir);
|
|
99
|
+
const idLines: string[] = ids
|
|
100
|
+
? [`OP_UID=${ids.uid}`, `OP_GID=${ids.gid}`]
|
|
101
|
+
: [];
|
|
95
102
|
|
|
96
103
|
return [
|
|
97
104
|
"# OpenPalm — System Configuration (managed by CLI/admin)",
|
|
98
105
|
"# Auto-generated fallback.",
|
|
99
106
|
"",
|
|
100
107
|
"# ── Authentication ──────────────────────────────────────────────────",
|
|
101
|
-
`
|
|
102
|
-
`OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
|
|
108
|
+
`OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
|
|
103
109
|
"",
|
|
104
110
|
"# ── Service Auth ─────────────────────────────────────────────────────",
|
|
105
|
-
`OP_MEMORY_TOKEN=${process.env.OP_MEMORY_TOKEN ?? ""}`,
|
|
106
111
|
"OP_OPENCODE_PASSWORD=",
|
|
107
112
|
"",
|
|
108
113
|
"# ── Paths ──────────────────────────────────────────────────────────",
|
|
109
114
|
`OP_HOME=${state.homeDir}`,
|
|
110
|
-
|
|
111
|
-
`OP_GID=${gid}`,
|
|
112
|
-
`OP_DOCKER_SOCK=${process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
|
|
115
|
+
...idLines,
|
|
113
116
|
"",
|
|
114
117
|
"# ── Images ──────────────────────────────────────────────────────────",
|
|
115
118
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
116
119
|
`OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
|
|
117
120
|
"",
|
|
118
121
|
"# ── Ports (38XX range) ──────────────────────────────────────────────",
|
|
122
|
+
"# Guardian is network-only (no host port) — channels reach it via",
|
|
123
|
+
"# http://guardian:8080 over the channel_lan Docker network.",
|
|
119
124
|
`OP_ASSISTANT_PORT=3800`,
|
|
120
125
|
`OP_ADMIN_PORT=3880`,
|
|
121
126
|
`OP_ADMIN_OPENCODE_PORT=3881`,
|
|
122
|
-
`OP_MEMORY_PORT=3898`,
|
|
123
|
-
`OP_GUARDIAN_PORT=3899`,
|
|
124
127
|
""
|
|
125
128
|
].join("\n");
|
|
126
129
|
}
|
|
@@ -143,8 +146,21 @@ export function discoverStackOverlays(stackDir: string): string[] {
|
|
|
143
146
|
.filter((e) => e.isDirectory())
|
|
144
147
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
145
148
|
for (const entry of entries) {
|
|
146
|
-
const
|
|
147
|
-
|
|
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}`);
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
|
|
@@ -191,19 +207,19 @@ function extractChannelSecrets(parsed: Record<string, string>): Record<string, s
|
|
|
191
207
|
}
|
|
192
208
|
|
|
193
209
|
/**
|
|
194
|
-
* Read channel HMAC secrets from
|
|
210
|
+
* Read channel HMAC secrets from config/stack/guardian.env.
|
|
195
211
|
*/
|
|
196
|
-
export function readChannelSecrets(
|
|
197
|
-
return extractChannelSecrets(parseEnvFile(`${
|
|
212
|
+
export function readChannelSecrets(stackDir: string): Record<string, string> {
|
|
213
|
+
return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
/**
|
|
201
|
-
* Write channel HMAC secrets to
|
|
217
|
+
* Write channel HMAC secrets to state/guardian.env.
|
|
202
218
|
* Merges with existing content; does not overwrite unrelated entries.
|
|
203
219
|
*/
|
|
204
|
-
export function writeChannelSecrets(
|
|
205
|
-
const guardianPath = `${
|
|
206
|
-
mkdirSync(
|
|
220
|
+
export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
|
|
221
|
+
const guardianPath = `${stackDir}/guardian.env`;
|
|
222
|
+
mkdirSync(stackDir, { recursive: true });
|
|
207
223
|
|
|
208
224
|
let base = "";
|
|
209
225
|
if (existsSync(guardianPath)) {
|
|
@@ -223,48 +239,107 @@ export function writeChannelSecrets(vaultDir: string, secrets: Record<string, st
|
|
|
223
239
|
chmodSync(guardianPath, 0o600);
|
|
224
240
|
}
|
|
225
241
|
|
|
242
|
+
// ── Volume Mount Targets ───────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse all enabled compose files and pre-create every host-side volume
|
|
246
|
+
* mount target as the current user. This prevents Docker from creating
|
|
247
|
+
* them as root-owned, which causes EACCES inside non-root containers.
|
|
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.
|
|
256
|
+
*
|
|
257
|
+
* Only mount sources under `state.homeDir` are touched; external paths
|
|
258
|
+
* (e.g. `/var/run/docker.sock`) are left alone.
|
|
259
|
+
*/
|
|
260
|
+
export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
261
|
+
const composeFiles = discoverStackOverlays(state.stackDir);
|
|
262
|
+
if (composeFiles.length === 0) return;
|
|
263
|
+
|
|
264
|
+
const envVars: Record<string, string> = {
|
|
265
|
+
...(process.env as Record<string, string>),
|
|
266
|
+
...parseEnvFile(`${state.stackDir}/stack.env`),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
for (const file of composeFiles) {
|
|
270
|
+
let doc: Record<string, unknown>;
|
|
271
|
+
try {
|
|
272
|
+
doc = yamlParse(readFileSync(file, 'utf-8')) as Record<string, unknown>;
|
|
273
|
+
} catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const services = doc?.services;
|
|
277
|
+
if (!services || typeof services !== 'object') continue;
|
|
278
|
+
|
|
279
|
+
for (const svc of Object.values(services as Record<string, unknown>)) {
|
|
280
|
+
if (!svc || typeof svc !== 'object') continue;
|
|
281
|
+
const svcRecord = svc as Record<string, unknown>;
|
|
282
|
+
if (!Array.isArray(svcRecord.volumes)) continue;
|
|
283
|
+
for (const vol of svcRecord.volumes as unknown[]) {
|
|
284
|
+
const volRecord = typeof vol === 'object' && vol !== null
|
|
285
|
+
? (vol as Record<string, unknown>)
|
|
286
|
+
: null;
|
|
287
|
+
const rawSource = typeof vol === 'string'
|
|
288
|
+
? vol.split(':')[0]
|
|
289
|
+
: String(volRecord?.source ?? '');
|
|
290
|
+
if (!rawSource) continue;
|
|
291
|
+
|
|
292
|
+
const hostPath = expandEnvVars(rawSource, envVars);
|
|
293
|
+
if (!hostPath || !hostPath.startsWith('/')) continue;
|
|
294
|
+
if (existsSync(hostPath)) continue;
|
|
295
|
+
|
|
296
|
+
// A basename containing a `.` (anywhere, including leading) is a file.
|
|
297
|
+
// Bare names like `stack` or `data` are directories.
|
|
298
|
+
const basename = hostPath.split('/').pop() ?? '';
|
|
299
|
+
const isFile = basename.includes('.');
|
|
300
|
+
|
|
301
|
+
if (isFile) {
|
|
302
|
+
mkdirSync(dirname(hostPath), { recursive: true });
|
|
303
|
+
writeFileSync(hostPath, '');
|
|
304
|
+
} else {
|
|
305
|
+
mkdirSync(hostPath, { recursive: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
226
312
|
// ── Persistence (direct-write to live paths) ────────────────────────
|
|
227
313
|
|
|
228
314
|
export function writeRuntimeFiles(
|
|
229
315
|
state: ControlPlaneState
|
|
230
316
|
): void {
|
|
231
|
-
// Write core compose to stack/
|
|
232
|
-
|
|
233
|
-
mkdirSync(stackDir, { recursive: true });
|
|
234
|
-
|
|
317
|
+
// Write core compose to config/stack/ only on first install —
|
|
318
|
+
// refreshCoreAssets() is the canonical writer on update.
|
|
319
|
+
mkdirSync(state.stackDir, { recursive: true });
|
|
320
|
+
const composePath = `${state.stackDir}/core.compose.yml`;
|
|
321
|
+
if (!existsSync(composePath)) {
|
|
322
|
+
writeFileSync(composePath, state.artifacts.compose);
|
|
323
|
+
}
|
|
235
324
|
|
|
236
325
|
// Load persisted channel HMAC secrets from guardian.env,
|
|
237
326
|
// then generate new ones for new channel addons.
|
|
238
|
-
const channelSecrets = readChannelSecrets(state.
|
|
239
|
-
const addonStackDir = `${state.homeDir}/stack`;
|
|
327
|
+
const channelSecrets = readChannelSecrets(state.stackDir);
|
|
240
328
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
241
|
-
const composePath = `${
|
|
329
|
+
const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
|
|
242
330
|
if (isChannelAddon(composePath) && !channelSecrets[addon]) {
|
|
243
331
|
channelSecrets[addon] = randomHex(16);
|
|
244
332
|
}
|
|
245
333
|
}
|
|
246
334
|
|
|
247
335
|
// Write channel secrets to guardian.env (the canonical source)
|
|
248
|
-
writeChannelSecrets(state.
|
|
336
|
+
writeChannelSecrets(state.stackDir, channelSecrets);
|
|
249
337
|
|
|
250
338
|
// Write system.env (no channel secrets — those live in guardian.env)
|
|
251
339
|
writeSystemEnv(state);
|
|
252
340
|
|
|
253
|
-
// Ensure
|
|
254
|
-
|
|
255
|
-
ensureSystemEnvSchema();
|
|
256
|
-
|
|
257
|
-
const spec = readStackSpec(state.configDir);
|
|
258
|
-
// Write OP_CAP_* capability vars to stack.env from stack spec
|
|
259
|
-
if (spec) {
|
|
260
|
-
writeCapabilityVars(spec, state.vaultDir);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Generate redact.env.schema from canonical mappings
|
|
264
|
-
const systemEnv = readStackEnv(state.vaultDir);
|
|
265
|
-
const redactDir = `${state.dataDir}/secrets`;
|
|
266
|
-
mkdirSync(redactDir, { recursive: true });
|
|
267
|
-
writeFileSync(`${redactDir}/redact.env.schema`, generateRedactSchema(systemEnv));
|
|
341
|
+
// Ensure state directory exists
|
|
342
|
+
mkdirSync(state.stateDir, { recursive: true });
|
|
268
343
|
|
|
269
344
|
state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
|
|
270
345
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { seedStashAssets } from "./core-assets.js";
|
|
6
|
+
|
|
7
|
+
describe("seedStashAssets", () => {
|
|
8
|
+
let homeDir: string;
|
|
9
|
+
const originalHome = process.env.OP_HOME;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
homeDir = mkdtempSync(join(tmpdir(), "stash-seed-test-"));
|
|
13
|
+
process.env.OP_HOME = homeDir;
|
|
14
|
+
mkdirSync(join(homeDir, "stash"), { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
process.env.OP_HOME = originalHome;
|
|
19
|
+
// Restore writable mode in case a test chmod'd the stash dir.
|
|
20
|
+
try {
|
|
21
|
+
chmodSync(join(homeDir, "stash"), 0o755);
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore — dir may not exist
|
|
24
|
+
}
|
|
25
|
+
rmSync(homeDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("writes every seed under stash/ on first run", () => {
|
|
29
|
+
const seeds = {
|
|
30
|
+
"skills/test-skill/SKILL.md": "---\nname: test-skill\ntype: skill\n---\nhello\n",
|
|
31
|
+
"commands/test-cmd.md": "---\nname: test-cmd\ntype: command\n---\nrun me\n",
|
|
32
|
+
};
|
|
33
|
+
const written = seedStashAssets(seeds);
|
|
34
|
+
|
|
35
|
+
expect(written.sort()).toEqual(Object.keys(seeds).sort());
|
|
36
|
+
for (const [rel, content] of Object.entries(seeds)) {
|
|
37
|
+
const target = join(homeDir, "stash", rel);
|
|
38
|
+
expect(existsSync(target)).toBe(true);
|
|
39
|
+
expect(readFileSync(target, "utf-8")).toBe(content);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not overwrite existing files (user edits win)", () => {
|
|
44
|
+
const seeds = { "skills/keep-mine/SKILL.md": "ORIGINAL SEED\n" };
|
|
45
|
+
const userEdit = "USER EDIT — must not be overwritten\n";
|
|
46
|
+
|
|
47
|
+
// Simulate a previous install: seed first.
|
|
48
|
+
seedStashAssets(seeds);
|
|
49
|
+
const target = join(homeDir, "stash/skills/keep-mine/SKILL.md");
|
|
50
|
+
expect(readFileSync(target, "utf-8")).toBe("ORIGINAL SEED\n");
|
|
51
|
+
|
|
52
|
+
// User edits the file.
|
|
53
|
+
writeFileSync(target, userEdit);
|
|
54
|
+
|
|
55
|
+
// Re-run: must return [] and leave the user's content intact.
|
|
56
|
+
const written = seedStashAssets(seeds);
|
|
57
|
+
expect(written).toEqual([]);
|
|
58
|
+
expect(readFileSync(target, "utf-8")).toBe(userEdit);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("creates nested directories under stash/ as needed", () => {
|
|
62
|
+
const seeds = { "skills/deep/nested/asset/SKILL.md": "x" };
|
|
63
|
+
seedStashAssets(seeds);
|
|
64
|
+
expect(existsSync(join(homeDir, "stash/skills/deep/nested/asset/SKILL.md"))).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns an empty list when called with no seeds", () => {
|
|
68
|
+
expect(seedStashAssets({})).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects seed keys that escape the stash directory", () => {
|
|
72
|
+
// Path-traversal guard: ../ sequences in keys must throw rather than
|
|
73
|
+
// silently writing outside stash/.
|
|
74
|
+
expect(() =>
|
|
75
|
+
seedStashAssets({ "../../etc/cron.d/evil": "owned\n" }),
|
|
76
|
+
).toThrow(/escapes stash dir/);
|
|
77
|
+
|
|
78
|
+
// Confirm the malicious payload was NOT written anywhere relative to
|
|
79
|
+
// the temp home.
|
|
80
|
+
expect(existsSync(join(homeDir, "..", "..", "etc", "cron.d", "evil"))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects seed keys that traverse through the stash dir back out", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
seedStashAssets({ "skills/../../../escape.md": "x" }),
|
|
86
|
+
).toThrow(/escapes stash dir/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("surfaces errors when the stash directory is read-only", () => {
|
|
90
|
+
// Skip when running as root (chmod is a no-op for the superuser).
|
|
91
|
+
const uid = process.getuid?.();
|
|
92
|
+
if (uid === 0) return;
|
|
93
|
+
|
|
94
|
+
const stashDir = join(homeDir, "stash");
|
|
95
|
+
chmodSync(stashDir, 0o555);
|
|
96
|
+
try {
|
|
97
|
+
expect(() =>
|
|
98
|
+
seedStashAssets({ "skills/readonly/SKILL.md": "nope\n" }),
|
|
99
|
+
).toThrow();
|
|
100
|
+
} finally {
|
|
101
|
+
chmodSync(stashDir, 0o755);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -3,95 +3,109 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Manages source-of-truth files for the ~/.openpalm/ layout:
|
|
5
5
|
* stack/ — compose runtime assets (core.compose.yml)
|
|
6
|
-
* vault/ — env schemas
|
|
7
6
|
*
|
|
8
7
|
* This module manages runtime-owned core files only.
|
|
9
8
|
* Registry catalog refresh is handled separately in registry.ts.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* is the responsibility of `refreshCoreAssets()` (GitHub download) or
|
|
13
|
-
* the CLI install command (which downloads assets before calling setup).
|
|
9
|
+
* Env validation has moved to `akm vault` + the in-house redactor — the
|
|
10
|
+
* historical `.env.schema` files (varlock format) were retired in #391.
|
|
14
11
|
*/
|
|
15
12
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import {
|
|
13
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
|
|
18
16
|
import { createLogger } from "../logger.js";
|
|
19
17
|
import { sha256 } from "./crypto.js";
|
|
20
18
|
|
|
21
19
|
const logger = createLogger("core-assets");
|
|
22
20
|
|
|
23
|
-
// ── Env Schema Files (vault/) ────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Ensure the user env schema directory exists and return the expected
|
|
27
|
-
* schema file path. The file itself may not exist yet — it is written
|
|
28
|
-
* by refreshCoreAssets() or the CLI install command.
|
|
29
|
-
*/
|
|
30
|
-
export function ensureUserEnvSchema(): string {
|
|
31
|
-
const vaultDir = resolveVaultDir();
|
|
32
|
-
const dir = `${vaultDir}/user`;
|
|
33
|
-
mkdirSync(dir, { recursive: true });
|
|
34
|
-
const path = `${dir}/user.env.schema`;
|
|
35
|
-
return path;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Ensure the system env schema directory exists and return the expected
|
|
40
|
-
* schema file path. The file itself may not exist yet — it is written
|
|
41
|
-
* by refreshCoreAssets() or the CLI install command.
|
|
42
|
-
*/
|
|
43
|
-
export function ensureSystemEnvSchema(): string {
|
|
44
|
-
const vaultDir = resolveVaultDir();
|
|
45
|
-
const dir = `${vaultDir}/stack`;
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const path = `${dir}/stack.env.schema`;
|
|
48
|
-
return path;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── Memory data directory ────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
export function ensureMemoryDir(dataDir?: string): string {
|
|
54
|
-
const resolved = dataDir ?? resolveDataDir();
|
|
55
|
-
const dir = `${resolved}/memory`;
|
|
56
|
-
mkdirSync(dir, { recursive: true });
|
|
57
|
-
return dir;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
21
|
// ── Core Compose (stack/) ─────────────────────────────────────────────
|
|
61
22
|
|
|
62
|
-
function coreComposePath(): string {
|
|
63
|
-
return `${resolveOpenPalmHome()}/stack/core.compose.yml`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
23
|
export function ensureCoreCompose(): string {
|
|
67
|
-
const path =
|
|
24
|
+
const path = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
|
|
68
25
|
mkdirSync(dirname(path), { recursive: true });
|
|
69
26
|
return path;
|
|
70
27
|
}
|
|
71
28
|
|
|
72
29
|
export function readCoreCompose(): string {
|
|
73
|
-
|
|
74
|
-
return readFileSync(path, "utf-8");
|
|
30
|
+
return readFileSync(`${resolveOpenPalmHome()}/config/stack/core.compose.yml`, "utf-8");
|
|
75
31
|
}
|
|
76
32
|
|
|
77
33
|
// ── OpenCode System Config ──────────────────────────────────────────
|
|
78
34
|
|
|
79
35
|
export function ensureOpenCodeSystemConfig(): void {
|
|
80
|
-
const dir = `${
|
|
36
|
+
const dir = `${resolveStateDir()}/assistant`;
|
|
81
37
|
mkdirSync(dir, { recursive: true });
|
|
82
38
|
}
|
|
83
39
|
|
|
40
|
+
// ── Shared akm stash (skills / commands / agents) ────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Seed the shared akm stash with built-in skills / commands / agents.
|
|
44
|
+
*
|
|
45
|
+
* Idempotent: **never overwrites** an existing file — user edits to a
|
|
46
|
+
* seeded asset always win, which preserves the same "config doesn't
|
|
47
|
+
* overwrite user edits" contract that governs the rest of OP_HOME.
|
|
48
|
+
*
|
|
49
|
+
* Returns the list of stash-relative paths that were actually written
|
|
50
|
+
* (empty on re-run when every seed already exists on disk).
|
|
51
|
+
*
|
|
52
|
+
* `seeds` is a map of stash-relative path → file content. Keys MUST be
|
|
53
|
+
* forward-slash relative paths that stay inside `data/stash/`; any key
|
|
54
|
+
* that escapes the stash directory after canonicalization throws,
|
|
55
|
+
* preventing a malicious caller from writing arbitrary files. Source of
|
|
56
|
+
* truth for the seeded files lives at `.openpalm/stash/` in the
|
|
57
|
+
* repo; the CLI embeds them at build time and passes the embedded
|
|
58
|
+
* record directly.
|
|
59
|
+
*/
|
|
60
|
+
export function seedStashAssets(seeds: Record<string, string>): string[] {
|
|
61
|
+
const stashDir = resolveStashDir();
|
|
62
|
+
const normalizedStash = resolve(stashDir);
|
|
63
|
+
const written: string[] = [];
|
|
64
|
+
for (const [relPath, content] of Object.entries(seeds)) {
|
|
65
|
+
const targetPath = join(stashDir, relPath);
|
|
66
|
+
const normalizedTarget = resolve(targetPath);
|
|
67
|
+
if (
|
|
68
|
+
normalizedTarget !== normalizedStash &&
|
|
69
|
+
!normalizedTarget.startsWith(normalizedStash + sep)
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(`Seed path escapes stash dir: ${relPath}`);
|
|
72
|
+
}
|
|
73
|
+
if (existsSync(targetPath)) continue;
|
|
74
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
75
|
+
writeFileSync(targetPath, content);
|
|
76
|
+
written.push(relPath);
|
|
77
|
+
}
|
|
78
|
+
return written;
|
|
79
|
+
}
|
|
80
|
+
|
|
84
81
|
// ── Asset Refresh (GitHub download) ──────────────────────────────────
|
|
85
82
|
|
|
86
83
|
const REPO = "itlackey/openpalm";
|
|
87
|
-
const VERSION = process.env.OP_ASSET_VERSION ?? "main";
|
|
88
84
|
|
|
85
|
+
function resolveAssetVersion(): string {
|
|
86
|
+
if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
|
|
87
|
+
try {
|
|
88
|
+
const pkgJson = JSON.parse(
|
|
89
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
|
|
90
|
+
);
|
|
91
|
+
return `v${pkgJson.version}`;
|
|
92
|
+
} catch {
|
|
93
|
+
return "main";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const VERSION = resolveAssetVersion();
|
|
97
|
+
|
|
98
|
+
// Persona files (openpalm.md, system.md), stash seeds, and user-editable config
|
|
99
|
+
// files are intentionally NOT in this list. They are seeded once (never
|
|
100
|
+
// overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
|
|
89
101
|
const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
90
|
-
{ relPath: "stack/core.compose.yml", githubFilename: ".openpalm/stack/core.compose.yml" },
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
{ relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
// Seeded once — written only when the file does not exist yet.
|
|
106
|
+
// User edits always win; upgrade never touches these files.
|
|
107
|
+
const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
108
|
+
{ relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
|
|
95
109
|
];
|
|
96
110
|
|
|
97
111
|
async function downloadAsset(filename: string): Promise<string> {
|
|
@@ -140,5 +154,44 @@ export async function refreshCoreAssets(): Promise<{
|
|
|
140
154
|
updated.push(asset.relPath);
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
// Seed user-editable assets only when missing — never overwrite.
|
|
158
|
+
for (const asset of SEEDED_ASSETS) {
|
|
159
|
+
const targetPath = join(homeDir, asset.relPath);
|
|
160
|
+
if (existsSync(targetPath)) continue;
|
|
161
|
+
const freshContent = await downloadAsset(asset.githubFilename);
|
|
162
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
163
|
+
writeFileSync(targetPath, freshContent);
|
|
164
|
+
updated.push(asset.relPath);
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
return { backupDir, updated };
|
|
144
168
|
}
|
|
169
|
+
|
|
170
|
+
// ── Assistant Persona File Seeding ────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Seed assistant persona files (openpalm.md, system.md) into OP_HOME.
|
|
174
|
+
*
|
|
175
|
+
* Idempotent: **never overwrites** an existing file — user edits always
|
|
176
|
+
* win. This preserves the "config/ is user-owned" contract: persona files
|
|
177
|
+
* are seeded once on first install and never touched again on update.
|
|
178
|
+
*
|
|
179
|
+
* `seeds` maps relative path keys (e.g. `"config/assistant/openpalm.md"`)
|
|
180
|
+
* to file content. Each file is written to `resolveOpenPalmHome()/<relPath>`
|
|
181
|
+
* only if the file does not already exist.
|
|
182
|
+
*
|
|
183
|
+
* Returns the list of relative paths that were actually written (empty on
|
|
184
|
+
* re-run when every seed already exists on disk).
|
|
185
|
+
*/
|
|
186
|
+
export function seedAssistantPersonaFiles(seeds: Record<string, string>): string[] {
|
|
187
|
+
const homeDir = resolveOpenPalmHome();
|
|
188
|
+
const written: string[] = [];
|
|
189
|
+
for (const [relPath, content] of Object.entries(seeds)) {
|
|
190
|
+
const targetPath = join(homeDir, relPath);
|
|
191
|
+
if (existsSync(targetPath)) continue;
|
|
192
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
193
|
+
writeFileSync(targetPath, content);
|
|
194
|
+
written.push(relPath);
|
|
195
|
+
}
|
|
196
|
+
return written;
|
|
197
|
+
}
|