@openpalm/lib 0.10.2 → 0.11.0-beta.2

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 (59) 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 +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  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 +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  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 +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  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/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ parseComposeStderr,
4
+ summarizeComposeStderr,
5
+ } from "./compose-errors.js";
6
+
7
+ describe("parseComposeStderr", () => {
8
+ it("returns empty for empty input", () => {
9
+ expect(parseComposeStderr("")).toEqual([]);
10
+ expect(parseComposeStderr("\n\n")).toEqual([]);
11
+ });
12
+
13
+ it("extracts pull access denied for a single service", () => {
14
+ const stderr = [
15
+ " Network openpalm_default Created",
16
+ " voice Pulling",
17
+ " voice Error pull access denied for openpalm/voice, repository does not exist or may require 'docker login'",
18
+ "Error response from daemon: pull access denied for openpalm/voice, repository does not exist or may require 'docker login': denied: requested access to the resource is denied",
19
+ ].join("\n");
20
+
21
+ const failures = parseComposeStderr(stderr);
22
+ expect(failures.length).toBeGreaterThanOrEqual(1);
23
+ expect(failures[0].service).toBe("voice");
24
+ expect(failures[0].reason).toMatch(/pull access denied/);
25
+ });
26
+
27
+ it("handles spinner / status prefix glyphs", () => {
28
+ const stderr = " ⠿ voice Error pull access denied for openpalm/voice";
29
+ const failures = parseComposeStderr(stderr);
30
+ expect(failures).toHaveLength(1);
31
+ expect(failures[0].service).toBe("voice");
32
+ expect(failures[0].reason).toMatch(/pull access denied/);
33
+ });
34
+
35
+ it("captures quoted Service failed lines", () => {
36
+ const stderr =
37
+ 'Service "discord" failed to build: failed to solve: process did not complete';
38
+ const failures = parseComposeStderr(stderr);
39
+ expect(failures).toHaveLength(1);
40
+ expect(failures[0].service).toBe("discord");
41
+ expect(failures[0].reason).toMatch(/failed to solve/);
42
+ });
43
+
44
+ it("deduplicates identical (service, reason) pairs", () => {
45
+ const stderr = [
46
+ "voice Error pull access denied for openpalm/voice",
47
+ "voice Error pull access denied for openpalm/voice",
48
+ ].join("\n");
49
+ const failures = parseComposeStderr(stderr);
50
+ expect(failures).toHaveLength(1);
51
+ });
52
+
53
+ it("returns multiple distinct failures", () => {
54
+ const stderr = [
55
+ "voice Error pull access denied for openpalm/voice",
56
+ "discord Error no such image: openpalm/discord:latest",
57
+ ].join("\n");
58
+ const failures = parseComposeStderr(stderr);
59
+ expect(failures).toHaveLength(2);
60
+ expect(failures.map((f) => f.service).sort()).toEqual(["discord", "voice"]);
61
+ });
62
+
63
+ it("falls back to image name when only daemon error is present", () => {
64
+ const stderr =
65
+ "Error response from daemon: pull access denied for openpalm/voice, repository does not exist";
66
+ const failures = parseComposeStderr(stderr);
67
+ expect(failures).toHaveLength(1);
68
+ expect(failures[0].service).toBe("openpalm/voice");
69
+ expect(failures[0].reason).toMatch(/pull access denied/);
70
+ });
71
+
72
+ it("ignores non-error noise (Pulling/Created/Started)", () => {
73
+ const stderr = [
74
+ " Network openpalm_default Created",
75
+ " Container openpalm-guardian-1 Started",
76
+ " assistant Pulling",
77
+ ].join("\n");
78
+ expect(parseComposeStderr(stderr)).toEqual([]);
79
+ });
80
+
81
+ it("does not treat 'Error response from daemon' as a service name", () => {
82
+ const stderr = "Error response from daemon: something bad happened";
83
+ // No service-prefixed line, no pull access denied, no quoted service —
84
+ // parser should NOT invent a service called "Error".
85
+ expect(parseComposeStderr(stderr)).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe("summarizeComposeStderr", () => {
90
+ it("returns first non-empty line", () => {
91
+ expect(summarizeComposeStderr("\n\n hello world \nnext line")).toBe(
92
+ "hello world"
93
+ );
94
+ });
95
+
96
+ it("truncates long lines", () => {
97
+ const long = "x".repeat(800);
98
+ const out = summarizeComposeStderr(long, 100);
99
+ expect(out.length).toBe(100);
100
+ expect(out.endsWith("…")).toBe(true);
101
+ });
102
+
103
+ it("returns empty string for empty input", () => {
104
+ expect(summarizeComposeStderr("")).toBe("");
105
+ });
106
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Parse `docker compose` stderr for per-service failures.
3
+ *
4
+ * `docker compose up -d` reports its progress on stderr — one or more
5
+ * status lines per service, plus a daemon-level "Error response from daemon"
6
+ * summary. When a single addon service fails to pull or start, the rest of
7
+ * the stack often comes up fine, so the only signal that anything is wrong
8
+ * is whatever appears on stderr. This helper extracts the per-service
9
+ * failure messages so callers can surface them to operators.
10
+ */
11
+ export type ComposeServiceFailure = {
12
+ service: string;
13
+ reason: string;
14
+ };
15
+
16
+ /**
17
+ * Lines we recognise as per-service failure indicators. The compose CLI
18
+ * has rendered these in a few different shapes across versions:
19
+ *
20
+ * "voice Error pull access denied for openpalm/voice ..."
21
+ * " ⠿ voice Error pull access denied for openpalm/voice ..."
22
+ * "Service \"voice\" failed to build: ..."
23
+ *
24
+ * We also pick up the bare daemon error and attribute it to the service
25
+ * named in nearby lines when no service-prefixed line is present.
26
+ */
27
+ const SERVICE_ERROR_RE = /^[\s⠦⠧⠇⠏⠋⠙⠹⠸⠼⠴⠿✔✘×]*\s*([A-Za-z0-9._-]+)\s+(Error|Failed|failed)\s+(.+)$/;
28
+ const SERVICE_FAILED_QUOTED_RE = /Service\s+["']([A-Za-z0-9._-]+)["']\s+failed[^:]*:\s*(.+)$/i;
29
+ const SERVICE_NOT_FOUND_RE = /no such service:\s*([A-Za-z0-9._-]+)/i;
30
+ const PULL_ACCESS_DENIED_RE = /pull access denied for\s+([^\s,]+)/i;
31
+
32
+ function pushUnique(
33
+ failures: ComposeServiceFailure[],
34
+ entry: ComposeServiceFailure
35
+ ): void {
36
+ const trimmed = { service: entry.service.trim(), reason: entry.reason.trim() };
37
+ if (!trimmed.service || !trimmed.reason) return;
38
+ const dup = failures.find(
39
+ (f) => f.service === trimmed.service && f.reason === trimmed.reason
40
+ );
41
+ if (!dup) failures.push(trimmed);
42
+ }
43
+
44
+ /**
45
+ * Best-effort extraction of failures from compose stderr.
46
+ *
47
+ * - Returns one entry per (service, reason) pair, in stderr order.
48
+ * - Does NOT fabricate service names: if a daemon error appears without
49
+ * any nearby service-prefixed line, the caller's intended-services list
50
+ * is used by the route, not this parser.
51
+ */
52
+ export function parseComposeStderr(stderr: string): ComposeServiceFailure[] {
53
+ const failures: ComposeServiceFailure[] = [];
54
+ if (!stderr) return failures;
55
+
56
+ const lines = stderr.split(/\r?\n/);
57
+
58
+ for (const raw of lines) {
59
+ const line = raw.replace(/\s+$/, "");
60
+ if (!line.trim()) continue;
61
+
62
+ const quoted = SERVICE_FAILED_QUOTED_RE.exec(line);
63
+ if (quoted) {
64
+ pushUnique(failures, { service: quoted[1], reason: quoted[2] });
65
+ continue;
66
+ }
67
+
68
+ const m = SERVICE_ERROR_RE.exec(line);
69
+ if (m) {
70
+ // Skip generic prefixes that look like services but aren't
71
+ // (e.g. "Error response from daemon ..." would match if the parser
72
+ // is too lenient — the verb word would be the second token).
73
+ const candidate = m[1];
74
+ if (candidate.toLowerCase() === "error") continue;
75
+ pushUnique(failures, { service: candidate, reason: m[3] });
76
+ continue;
77
+ }
78
+
79
+ const notFound = SERVICE_NOT_FOUND_RE.exec(line);
80
+ if (notFound) {
81
+ pushUnique(failures, {
82
+ service: notFound[1],
83
+ reason: `no such service: ${notFound[1]}`,
84
+ });
85
+ continue;
86
+ }
87
+ }
88
+
89
+ // If we still found nothing but the stderr clearly mentions a pull
90
+ // access denied, surface the offending image as the "service" identifier
91
+ // — better than swallowing the failure entirely.
92
+ if (failures.length === 0) {
93
+ const denied = PULL_ACCESS_DENIED_RE.exec(stderr);
94
+ if (denied) {
95
+ pushUnique(failures, {
96
+ service: denied[1],
97
+ reason: `pull access denied for ${denied[1]}`,
98
+ });
99
+ }
100
+ }
101
+
102
+ return failures;
103
+ }
104
+
105
+ /**
106
+ * Summarise compose stderr in a single short line, suitable for log
107
+ * envelopes / API error messages when no per-service parse succeeded.
108
+ * Returns the first non-empty stderr line, capped.
109
+ */
110
+ export function summarizeComposeStderr(stderr: string, maxLen = 500): string {
111
+ if (!stderr) return "";
112
+ const first = stderr
113
+ .split(/\r?\n/)
114
+ .map((l) => l.trim())
115
+ .find((l) => l.length > 0) ?? "";
116
+ return first.length > maxLen ? first.slice(0, maxLen - 1) + "…" : first;
117
+ }
@@ -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,15 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
98
85
  "# Auto-generated fallback.",
99
86
  "",
100
87
  "# ── Authentication ──────────────────────────────────────────────────",
101
- `OP_ADMIN_TOKEN=\${OP_ADMIN_TOKEN}`,
102
- `OP_ASSISTANT_TOKEN=\${OP_ASSISTANT_TOKEN}`,
88
+ `OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
103
89
  "",
104
90
  "# ── Service Auth ─────────────────────────────────────────────────────",
105
- `OP_MEMORY_TOKEN=${process.env.OP_MEMORY_TOKEN ?? ""}`,
106
91
  "OP_OPENCODE_PASSWORD=",
107
92
  "",
108
93
  "# ── Paths ──────────────────────────────────────────────────────────",
109
94
  `OP_HOME=${state.homeDir}`,
110
95
  `OP_UID=${uid}`,
111
96
  `OP_GID=${gid}`,
112
- `OP_DOCKER_SOCK=${process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock"}`,
113
97
  "",
114
98
  "# ── Images ──────────────────────────────────────────────────────────",
115
99
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
@@ -119,7 +103,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
119
103
  `OP_ASSISTANT_PORT=3800`,
120
104
  `OP_ADMIN_PORT=3880`,
121
105
  `OP_ADMIN_OPENCODE_PORT=3881`,
122
- `OP_MEMORY_PORT=3898`,
123
106
  `OP_GUARDIAN_PORT=3899`,
124
107
  ""
125
108
  ].join("\n");
@@ -191,19 +174,19 @@ function extractChannelSecrets(parsed: Record<string, string>): Record<string, s
191
174
  }
192
175
 
193
176
  /**
194
- * Read channel HMAC secrets from vault/stack/guardian.env.
177
+ * Read channel HMAC secrets from config/stack/guardian.env.
195
178
  */
196
- export function readChannelSecrets(vaultDir: string): Record<string, string> {
197
- return extractChannelSecrets(parseEnvFile(`${vaultDir}/stack/guardian.env`));
179
+ export function readChannelSecrets(stackDir: string): Record<string, string> {
180
+ return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
198
181
  }
199
182
 
200
183
  /**
201
- * Write channel HMAC secrets to vault/stack/guardian.env.
184
+ * Write channel HMAC secrets to state/guardian.env.
202
185
  * Merges with existing content; does not overwrite unrelated entries.
203
186
  */
204
- export function writeChannelSecrets(vaultDir: string, secrets: Record<string, string>): void {
205
- const guardianPath = `${vaultDir}/stack/guardian.env`;
206
- mkdirSync(`${vaultDir}/stack`, { recursive: true });
187
+ export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
188
+ const guardianPath = `${stackDir}/guardian.env`;
189
+ mkdirSync(stackDir, { recursive: true });
207
190
 
208
191
  let base = "";
209
192
  if (existsSync(guardianPath)) {
@@ -223,48 +206,103 @@ export function writeChannelSecrets(vaultDir: string, secrets: Record<string, st
223
206
  chmodSync(guardianPath, 0o600);
224
207
  }
225
208
 
209
+ // ── Volume Mount Targets ───────────────────────────────────────────────
210
+
211
+ /**
212
+ * Parse all enabled compose files and pre-create every host-side volume
213
+ * mount target as the current user. This prevents Docker from creating
214
+ * them as root-owned, which causes EACCES inside non-root containers.
215
+ *
216
+ * For file mounts (basename contains a `.`), creates an empty file.
217
+ * For directory mounts (basename has no `.`), creates the directory.
218
+ *
219
+ * Heuristic: a basename containing a `.` is treated as a file. This
220
+ * intentionally includes leading-dot files (e.g. `.env`) because Docker
221
+ * bind mounts to them must be regular files. Bare directory names like
222
+ * `stack` or `addons` lack extensions and are created as directories.
223
+ *
224
+ * Only mount sources under `state.homeDir` are touched; external paths
225
+ * (e.g. `/var/run/docker.sock`) are left alone.
226
+ */
227
+ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
228
+ const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
229
+ if (composeFiles.length === 0) return;
230
+
231
+ const envVars: Record<string, string> = {
232
+ ...(process.env as Record<string, string>),
233
+ ...parseEnvFile(`${state.stackDir}/stack.env`),
234
+ };
235
+
236
+ for (const file of composeFiles) {
237
+ let doc: Record<string, unknown>;
238
+ try {
239
+ doc = yamlParse(readFileSync(file, 'utf-8')) as Record<string, unknown>;
240
+ } catch {
241
+ continue;
242
+ }
243
+ const services = doc?.services;
244
+ if (!services || typeof services !== 'object') continue;
245
+
246
+ for (const svc of Object.values(services as Record<string, unknown>)) {
247
+ if (!svc || typeof svc !== 'object') continue;
248
+ const svcRecord = svc as Record<string, unknown>;
249
+ if (!Array.isArray(svcRecord.volumes)) continue;
250
+ for (const vol of svcRecord.volumes as unknown[]) {
251
+ const volRecord = typeof vol === 'object' && vol !== null
252
+ ? (vol as Record<string, unknown>)
253
+ : null;
254
+ const rawSource = typeof vol === 'string'
255
+ ? vol.split(':')[0]
256
+ : String(volRecord?.source ?? '');
257
+ if (!rawSource) continue;
258
+
259
+ const hostPath = expandEnvVars(rawSource, envVars);
260
+ if (!hostPath || !hostPath.startsWith('/')) continue;
261
+ if (existsSync(hostPath)) continue;
262
+
263
+ // A basename containing a `.` (anywhere, including leading) is a file.
264
+ // Bare names like `stack` or `data` are directories.
265
+ const basename = hostPath.split('/').pop() ?? '';
266
+ const isFile = basename.includes('.');
267
+
268
+ if (isFile) {
269
+ mkdirSync(dirname(hostPath), { recursive: true });
270
+ writeFileSync(hostPath, '');
271
+ } else {
272
+ mkdirSync(hostPath, { recursive: true });
273
+ }
274
+ }
275
+ }
276
+ }
277
+ }
278
+
226
279
  // ── Persistence (direct-write to live paths) ────────────────────────
227
280
 
228
281
  export function writeRuntimeFiles(
229
282
  state: ControlPlaneState
230
283
  ): 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);
284
+ // Write core compose to config/stack/
285
+ mkdirSync(state.stackDir, { recursive: true });
286
+ writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
235
287
 
236
288
  // Load persisted channel HMAC secrets from guardian.env,
237
289
  // then generate new ones for new channel addons.
238
- const channelSecrets = readChannelSecrets(state.vaultDir);
239
- const addonStackDir = `${state.homeDir}/stack`;
290
+ const channelSecrets = readChannelSecrets(state.stackDir);
240
291
  for (const addon of listEnabledAddonIds(state.homeDir)) {
241
- const composePath = `${addonStackDir}/addons/${addon}/compose.yml`;
292
+ const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
242
293
  if (isChannelAddon(composePath) && !channelSecrets[addon]) {
243
294
  channelSecrets[addon] = randomHex(16);
244
295
  }
245
296
  }
246
297
 
247
298
  // Write channel secrets to guardian.env (the canonical source)
248
- writeChannelSecrets(state.vaultDir, channelSecrets);
299
+ writeChannelSecrets(state.stackDir, channelSecrets);
249
300
 
250
301
  // Write system.env (no channel secrets — those live in guardian.env)
251
302
  writeSystemEnv(state);
252
303
 
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));
304
+ // Ensure state directory exists
305
+ mkdirSync(state.stateDir, { recursive: true });
268
306
 
269
307
  state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
270
308
  }
@@ -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
+ });