@openpalm/lib 0.10.1 → 0.11.0-beta.1

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 (55) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. package/src/control-plane/redact-schema.ts +0 -50
@@ -15,16 +15,17 @@ import type { ControlPlaneState } from "./types.js";
15
15
  let tempDir: string;
16
16
 
17
17
  function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneState {
18
+ const configDir = join(tempDir, "config");
18
19
  return {
19
20
  adminToken: "test",
20
21
  assistantToken: "test",
21
- setupToken: "test",
22
22
  homeDir: tempDir,
23
- configDir: join(tempDir, "config"),
24
- vaultDir: join(tempDir, "vault"),
25
- dataDir: join(tempDir, "data"),
26
- logsDir: join(tempDir, "logs"),
23
+ configDir,
24
+ stashDir: join(tempDir, "stash"),
25
+ workspaceDir: join(tempDir, "workspace"),
27
26
  cacheDir: join(tempDir, "cache"),
27
+ stateDir: join(tempDir, "state"),
28
+ stackDir: join(configDir, "stack"),
28
29
  services: {},
29
30
  artifacts: { compose: "" },
30
31
  artifactMeta: [],
@@ -34,28 +35,25 @@ function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneStat
34
35
  }
35
36
 
36
37
  function seedCoreCompose(): void {
37
- const stackDir = join(tempDir, "stack");
38
+ const stackDir = join(tempDir, "config", "stack");
38
39
  mkdirSync(stackDir, { recursive: true });
39
40
  writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
40
41
  }
41
42
 
42
- function seedEnvFiles(files: { stack?: boolean; user?: boolean; guardian?: boolean } = {}): void {
43
+ function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
44
+ const stackDir = join(tempDir, "config", "stack");
43
45
  if (files.stack) {
44
- mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
45
- writeFileSync(join(tempDir, "vault", "stack", "stack.env"), "KEY=val");
46
- }
47
- if (files.user) {
48
- mkdirSync(join(tempDir, "vault", "user"), { recursive: true });
49
- writeFileSync(join(tempDir, "vault", "user", "user.env"), "SECRET=val");
46
+ mkdirSync(stackDir, { recursive: true });
47
+ writeFileSync(join(stackDir, "stack.env"), "KEY=val");
50
48
  }
51
49
  if (files.guardian) {
52
- mkdirSync(join(tempDir, "vault", "stack"), { recursive: true });
53
- writeFileSync(join(tempDir, "vault", "stack", "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
50
+ mkdirSync(stackDir, { recursive: true });
51
+ writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
54
52
  }
55
53
  }
56
54
 
57
55
  function seedAddon(name: string): void {
58
- const addonDir = join(tempDir, "stack", "addons", name);
56
+ const addonDir = join(tempDir, "config", "stack", "addons", name);
59
57
  mkdirSync(addonDir, { recursive: true });
60
58
  writeFileSync(join(addonDir, "compose.yml"), "services: {}");
61
59
  }
@@ -98,13 +96,16 @@ describe("buildComposeOptions", () => {
98
96
  });
99
97
 
100
98
  it("returns env files in correct order", () => {
101
- seedEnvFiles({ stack: true, user: true, guardian: true });
99
+ // Note: vault/user/user.env is no longer a
100
+ // compose env_file. The runtime env file list is: stack.env, guardian.env.
101
+ // Even when a legacy user.env is present on disk, it is intentionally
102
+ // excluded from the compose args.
103
+ seedEnvFiles({ stack: true, guardian: true });
102
104
  const state = makeState();
103
105
  const opts = buildComposeOptions(state);
104
- expect(opts.envFiles).toHaveLength(3);
106
+ expect(opts.envFiles).toHaveLength(2);
105
107
  expect(opts.envFiles[0]).toContain("stack.env");
106
- expect(opts.envFiles[1]).toContain("user.env");
107
- expect(opts.envFiles[2]).toContain("guardian.env");
108
+ expect(opts.envFiles[1]).toContain("guardian.env");
108
109
  });
109
110
 
110
111
  it("excludes missing env files", () => {
@@ -136,8 +137,11 @@ describe("buildComposeCliArgs", () => {
136
137
  });
137
138
 
138
139
  it("includes --env-file flags for env files that exist", () => {
140
+ // Note: vault/user/user.env is no longer
141
+ // listed in the compose env_file set. Only stack.env and guardian.env
142
+ // (when present) are passed via --env-file.
139
143
  seedCoreCompose();
140
- seedEnvFiles({ stack: true, user: true });
144
+ seedEnvFiles({ stack: true, guardian: true });
141
145
  const state = makeState();
142
146
  const args = buildComposeCliArgs(state);
143
147
  const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
@@ -6,67 +6,54 @@
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
15
 
16
- import { generateRedactSchema } from "./redact-schema.js";
17
- import { readStackEnv } from "./secrets.js";
18
16
  import {
19
17
  readCoreCompose,
20
- ensureUserEnvSchema,
21
- ensureSystemEnvSchema,
22
18
  } from "./core-assets.js";
23
19
  export { sha256, randomHex } from "./crypto.js";
24
20
  import { sha256, randomHex } from "./crypto.js";
25
21
 
26
22
  const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
27
23
 
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
24
  // ── Env File Management ──────────────────────────────────────────────
45
25
 
46
26
  /**
47
27
  * Return the env files used for docker compose --env-file args.
48
28
  * These are the live vault env files.
49
29
  *
50
- * Order: stack.env -> user.env -> guardian.env
30
+ * Order: stack.env -> guardian.env
31
+ *
32
+ * Note: `vault/user/user.env` is no longer a
33
+ * compose env_file. User-managed env secrets live in the akm
34
+ * `vault:user` store and are sourced by the assistant entrypoint at
35
+ * container startup. The legacy file is migrated into akm and deleted
36
+ * on upgrade; subsequent `docker compose` invocations must not reference
37
+ * it (compose interpolates `${VAR}` against the merged --env-file
38
+ * contents, and a stale user.env would shadow the akm-sourced values).
51
39
  */
52
40
  export function buildEnvFiles(state: ControlPlaneState): string[] {
53
41
  return [
54
- `${state.vaultDir}/stack/stack.env`,
55
- `${state.vaultDir}/user/user.env`,
56
- `${state.vaultDir}/stack/guardian.env`,
42
+ `${state.stackDir}/stack.env`,
43
+ `${state.stackDir}/guardian.env`,
57
44
  ].filter(existsSync);
58
45
  }
59
46
 
60
47
  /**
61
- * Write system-managed values to vault/stack/stack.env.
48
+ * Write system-managed values to config/stack/stack.env.
62
49
  *
63
50
  * Channel HMAC secrets are NOT written here — they belong in guardian.env.
64
51
  * Use writeChannelSecrets() for channel secrets.
65
52
  */
66
53
  export function writeSystemEnv(state: ControlPlaneState): void {
67
- mkdirSync(`${state.vaultDir}/stack`, { recursive: true });
54
+ mkdirSync(state.stackDir, { recursive: true });
68
55
 
69
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
56
+ const systemEnvPath = `${state.stackDir}/stack.env`;
70
57
 
71
58
  let base = "";
72
59
  if (existsSync(systemEnvPath)) {
@@ -98,18 +85,16 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
98
85
  "# Auto-generated fallback.",
99
86
  "",
100
87
  "# ── Authentication ──────────────────────────────────────────────────",
101
- `OP_ADMIN_TOKEN=\${OP_ADMIN_TOKEN}`,
88
+ `OP_UI_TOKEN=\${OP_UI_TOKEN}`,
102
89
  `OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
103
90
  "",
104
91
  "# ── Service Auth ─────────────────────────────────────────────────────",
105
- `OP_MEMORY_TOKEN=${process.env.OP_MEMORY_TOKEN ?? ""}`,
106
92
  "OP_OPENCODE_PASSWORD=",
107
93
  "",
108
94
  "# ── Paths ──────────────────────────────────────────────────────────",
109
95
  `OP_HOME=${state.homeDir}`,
110
96
  `OP_UID=${uid}`,
111
97
  `OP_GID=${gid}`,
112
- `OP_DOCKER_SOCK=${process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
113
98
  "",
114
99
  "# ── Images ──────────────────────────────────────────────────────────",
115
100
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
@@ -119,7 +104,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
119
104
  `OP_ASSISTANT_PORT=3800`,
120
105
  `OP_ADMIN_PORT=3880`,
121
106
  `OP_ADMIN_OPENCODE_PORT=3881`,
122
- `OP_MEMORY_PORT=3898`,
123
107
  `OP_GUARDIAN_PORT=3899`,
124
108
  ""
125
109
  ].join("\n");
@@ -191,19 +175,19 @@ function extractChannelSecrets(parsed: Record<string, string>): Record<string, s
191
175
  }
192
176
 
193
177
  /**
194
- * Read channel HMAC secrets from vault/stack/guardian.env.
178
+ * Read channel HMAC secrets from config/stack/guardian.env.
195
179
  */
196
- export function readChannelSecrets(vaultDir: string): Record<string, string> {
197
- return extractChannelSecrets(parseEnvFile(`${vaultDir}/stack/guardian.env`));
180
+ export function readChannelSecrets(stackDir: string): Record<string, string> {
181
+ return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
198
182
  }
199
183
 
200
184
  /**
201
- * Write channel HMAC secrets to vault/stack/guardian.env.
185
+ * Write channel HMAC secrets to state/guardian.env.
202
186
  * Merges with existing content; does not overwrite unrelated entries.
203
187
  */
204
- export function writeChannelSecrets(vaultDir: string, secrets: Record<string, string>): void {
205
- const guardianPath = `${vaultDir}/stack/guardian.env`;
206
- mkdirSync(`${vaultDir}/stack`, { recursive: true });
188
+ export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
189
+ const guardianPath = `${stackDir}/guardian.env`;
190
+ mkdirSync(stackDir, { recursive: true });
207
191
 
208
192
  let base = "";
209
193
  if (existsSync(guardianPath)) {
@@ -223,48 +207,103 @@ export function writeChannelSecrets(vaultDir: string, secrets: Record<string, st
223
207
  chmodSync(guardianPath, 0o600);
224
208
  }
225
209
 
210
+ // ── Volume Mount Targets ───────────────────────────────────────────────
211
+
212
+ /**
213
+ * Parse all enabled compose files and pre-create every host-side volume
214
+ * mount target as the current user. This prevents Docker from creating
215
+ * them as root-owned, which causes EACCES inside non-root containers.
216
+ *
217
+ * For file mounts (basename contains a `.`), creates an empty file.
218
+ * For directory mounts (basename has no `.`), creates the directory.
219
+ *
220
+ * Heuristic: a basename containing a `.` is treated as a file. This
221
+ * intentionally includes leading-dot files (e.g. `.env`) because Docker
222
+ * bind mounts to them must be regular files. Bare directory names like
223
+ * `stack` or `addons` lack extensions and are created as directories.
224
+ *
225
+ * Only mount sources under `state.homeDir` are touched; external paths
226
+ * (e.g. `/var/run/docker.sock`) are left alone.
227
+ */
228
+ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
229
+ const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
230
+ if (composeFiles.length === 0) return;
231
+
232
+ const envVars: Record<string, string> = {
233
+ ...(process.env as Record<string, string>),
234
+ ...parseEnvFile(`${state.stackDir}/stack.env`),
235
+ };
236
+
237
+ for (const file of composeFiles) {
238
+ let doc: Record<string, unknown>;
239
+ try {
240
+ doc = yamlParse(readFileSync(file, 'utf-8')) as Record<string, unknown>;
241
+ } catch {
242
+ continue;
243
+ }
244
+ const services = doc?.services;
245
+ if (!services || typeof services !== 'object') continue;
246
+
247
+ for (const svc of Object.values(services as Record<string, unknown>)) {
248
+ if (!svc || typeof svc !== 'object') continue;
249
+ const svcRecord = svc as Record<string, unknown>;
250
+ if (!Array.isArray(svcRecord.volumes)) continue;
251
+ for (const vol of svcRecord.volumes as unknown[]) {
252
+ const volRecord = typeof vol === 'object' && vol !== null
253
+ ? (vol as Record<string, unknown>)
254
+ : null;
255
+ const rawSource = typeof vol === 'string'
256
+ ? vol.split(':')[0]
257
+ : String(volRecord?.source ?? '');
258
+ if (!rawSource) continue;
259
+
260
+ const hostPath = expandEnvVars(rawSource, envVars);
261
+ if (!hostPath || !hostPath.startsWith('/')) continue;
262
+ if (existsSync(hostPath)) continue;
263
+
264
+ // A basename containing a `.` (anywhere, including leading) is a file.
265
+ // Bare names like `stack` or `data` are directories.
266
+ const basename = hostPath.split('/').pop() ?? '';
267
+ const isFile = basename.includes('.');
268
+
269
+ if (isFile) {
270
+ mkdirSync(dirname(hostPath), { recursive: true });
271
+ writeFileSync(hostPath, '');
272
+ } else {
273
+ mkdirSync(hostPath, { recursive: true });
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+
226
280
  // ── Persistence (direct-write to live paths) ────────────────────────
227
281
 
228
282
  export function writeRuntimeFiles(
229
283
  state: ControlPlaneState
230
284
  ): 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);
285
+ // Write core compose to config/stack/
286
+ mkdirSync(state.stackDir, { recursive: true });
287
+ writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
235
288
 
236
289
  // Load persisted channel HMAC secrets from guardian.env,
237
290
  // then generate new ones for new channel addons.
238
- const channelSecrets = readChannelSecrets(state.vaultDir);
239
- const addonStackDir = `${state.homeDir}/stack`;
291
+ const channelSecrets = readChannelSecrets(state.stackDir);
240
292
  for (const addon of listEnabledAddonIds(state.homeDir)) {
241
- const composePath = `${addonStackDir}/addons/${addon}/compose.yml`;
293
+ const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
242
294
  if (isChannelAddon(composePath) && !channelSecrets[addon]) {
243
295
  channelSecrets[addon] = randomHex(16);
244
296
  }
245
297
  }
246
298
 
247
299
  // Write channel secrets to guardian.env (the canonical source)
248
- writeChannelSecrets(state.vaultDir, channelSecrets);
300
+ writeChannelSecrets(state.stackDir, channelSecrets);
249
301
 
250
302
  // Write system.env (no channel secrets — those live in guardian.env)
251
303
  writeSystemEnv(state);
252
304
 
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));
305
+ // Ensure state directory exists
306
+ mkdirSync(state.stateDir, { recursive: true });
268
307
 
269
308
  state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
270
309
  }
@@ -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,92 @@
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 { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
18
15
  import { createLogger } from "../logger.js";
19
16
  import { sha256 } from "./crypto.js";
20
17
 
21
18
  const logger = createLogger("core-assets");
22
19
 
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
20
  // ── Core Compose (stack/) ─────────────────────────────────────────────
61
21
 
62
- function coreComposePath(): string {
63
- return `${resolveOpenPalmHome()}/stack/core.compose.yml`;
64
- }
65
-
66
22
  export function ensureCoreCompose(): string {
67
- const path = coreComposePath();
23
+ const path = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
68
24
  mkdirSync(dirname(path), { recursive: true });
69
25
  return path;
70
26
  }
71
27
 
72
28
  export function readCoreCompose(): string {
73
- const path = coreComposePath();
74
- return readFileSync(path, "utf-8");
29
+ return readFileSync(`${resolveOpenPalmHome()}/config/stack/core.compose.yml`, "utf-8");
75
30
  }
76
31
 
77
32
  // ── OpenCode System Config ──────────────────────────────────────────
78
33
 
79
34
  export function ensureOpenCodeSystemConfig(): void {
80
- const dir = `${resolveDataDir()}/assistant`;
35
+ const dir = `${resolveStateDir()}/assistant`;
81
36
  mkdirSync(dir, { recursive: true });
82
37
  }
83
38
 
39
+ // ── Shared akm stash (skills / commands / agents) ────────────────────
40
+
41
+ /**
42
+ * Seed the shared akm stash with built-in skills / commands / agents.
43
+ *
44
+ * Idempotent: **never overwrites** an existing file — user edits to a
45
+ * seeded asset always win, which preserves the same "config doesn't
46
+ * overwrite user edits" contract that governs the rest of OP_HOME.
47
+ *
48
+ * Returns the list of stash-relative paths that were actually written
49
+ * (empty on re-run when every seed already exists on disk).
50
+ *
51
+ * `seeds` is a map of stash-relative path → file content. Keys MUST be
52
+ * forward-slash relative paths that stay inside `data/stash/`; any key
53
+ * that escapes the stash directory after canonicalization throws,
54
+ * preventing a malicious caller from writing arbitrary files. Source of
55
+ * truth for the seeded files lives at `.openpalm/stash/` in the
56
+ * repo; the CLI embeds them at build time and passes the embedded
57
+ * record directly.
58
+ */
59
+ export function seedStashAssets(seeds: Record<string, string>): string[] {
60
+ const stashDir = resolveStashDir();
61
+ const normalizedStash = resolve(stashDir);
62
+ const written: string[] = [];
63
+ for (const [relPath, content] of Object.entries(seeds)) {
64
+ const targetPath = join(stashDir, relPath);
65
+ const normalizedTarget = resolve(targetPath);
66
+ if (
67
+ normalizedTarget !== normalizedStash &&
68
+ !normalizedTarget.startsWith(normalizedStash + sep)
69
+ ) {
70
+ throw new Error(`Seed path escapes stash dir: ${relPath}`);
71
+ }
72
+ if (existsSync(targetPath)) continue;
73
+ mkdirSync(dirname(targetPath), { recursive: true });
74
+ writeFileSync(targetPath, content);
75
+ written.push(relPath);
76
+ }
77
+ return written;
78
+ }
79
+
84
80
  // ── Asset Refresh (GitHub download) ──────────────────────────────────
85
81
 
86
82
  const REPO = "itlackey/openpalm";
87
83
  const VERSION = process.env.OP_ASSET_VERSION ?? "main";
88
84
 
85
+ // Stash seeds are intentionally NOT in this list — they use seedStashAssets()
86
+ // which never overwrites existing files (user edits win on re-install).
89
87
  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" },
88
+ { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
89
+ { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
90
+ { relPath: "config/assistant/openpalm.md", githubFilename: ".openpalm/config/assistant/openpalm.md" },
91
+ { relPath: "config/assistant/system.md", githubFilename: ".openpalm/config/assistant/system.md" },
95
92
  ];
96
93
 
97
94
  async function downloadAsset(filename: string): Promise<string> {