@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.
Files changed (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. 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 { parseEnvFile, mergeEnvContent } from './env.js';
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 -> user.env -> guardian.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.vaultDir}/stack/stack.env`,
55
- `${state.vaultDir}/user/user.env`,
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 vault/stack/stack.env.
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(`${state.vaultDir}/stack`, { recursive: true });
55
+ mkdirSync(state.stackDir, { recursive: true });
68
56
 
69
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
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
- const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
94
- const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
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
- `OP_ADMIN_TOKEN=\${OP_ADMIN_TOKEN}`,
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
- `OP_UID=${uid}`,
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 addonCompose = `${addonsDir}/${entry.name}/compose.yml`;
147
- if (existsSync(addonCompose)) files.push(addonCompose);
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 vault/stack/guardian.env.
210
+ * Read channel HMAC secrets from config/stack/guardian.env.
195
211
  */
196
- export function readChannelSecrets(vaultDir: string): Record<string, string> {
197
- return extractChannelSecrets(parseEnvFile(`${vaultDir}/stack/guardian.env`));
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 vault/stack/guardian.env.
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(vaultDir: string, secrets: Record<string, string>): void {
205
- const guardianPath = `${vaultDir}/stack/guardian.env`;
206
- mkdirSync(`${vaultDir}/stack`, { recursive: true });
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
- const stackDir = `${state.homeDir}/stack`;
233
- mkdirSync(stackDir, { recursive: true });
234
- writeFileSync(`${stackDir}/core.compose.yml`, state.artifacts.compose);
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.vaultDir);
239
- const addonStackDir = `${state.homeDir}/stack`;
327
+ const channelSecrets = readChannelSecrets(state.stackDir);
240
328
  for (const addon of listEnabledAddonIds(state.homeDir)) {
241
- const composePath = `${addonStackDir}/addons/${addon}/compose.yml`;
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.vaultDir, channelSecrets);
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 env schema directories exist
254
- ensureUserEnvSchema();
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
- * All ensure* functions verify that the expected files exist at OP_HOME.
11
- * They create directories as needed but do NOT write file content — that
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 { resolveDataDir, resolveVaultDir, resolveOpenPalmHome, resolveBackupsDir } from "./home.js";
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 = coreComposePath();
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
- const path = coreComposePath();
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 = `${resolveDataDir()}/assistant`;
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
- { relPath: "data/assistant/opencode.jsonc", githubFilename: "core/assistant/opencode/opencode.jsonc" },
92
- { relPath: "data/assistant/AGENTS.md", githubFilename: "core/assistant/opencode/AGENTS.md" },
93
- { relPath: "vault/user/user.env.schema", githubFilename: ".openpalm/vault/user/user.env.schema" },
94
- { relPath: "vault/stack/stack.env.schema", githubFilename: ".openpalm/vault/stack/stack.env.schema" },
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
+ }