@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.13

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 (54) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-user-env.test.ts +113 -0
  4. package/src/control-plane/akm-user-env.ts +144 -0
  5. package/src/control-plane/backup.ts +14 -5
  6. package/src/control-plane/channels.ts +48 -29
  7. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  8. package/src/control-plane/compose-args.test.ts +90 -31
  9. package/src/control-plane/compose-args.ts +119 -9
  10. package/src/control-plane/config-persistence.ts +87 -133
  11. package/src/control-plane/core-assets.test.ts +9 -9
  12. package/src/control-plane/core-assets.ts +24 -8
  13. package/src/control-plane/docker.ts +15 -14
  14. package/src/control-plane/env.test.ts +10 -10
  15. package/src/control-plane/env.ts +1 -1
  16. package/src/control-plane/extends-support.test.ts +8 -8
  17. package/src/control-plane/home.ts +34 -46
  18. package/src/control-plane/host-opencode.test.ts +82 -10
  19. package/src/control-plane/host-opencode.ts +42 -13
  20. package/src/control-plane/install-edge-cases.test.ts +94 -102
  21. package/src/control-plane/install-lock.ts +7 -7
  22. package/src/control-plane/lifecycle.ts +36 -34
  23. package/src/control-plane/markdown-task.ts +30 -50
  24. package/src/control-plane/paths.ts +62 -42
  25. package/src/control-plane/profile-ids.ts +21 -0
  26. package/src/control-plane/provider-models.ts +3 -3
  27. package/src/control-plane/registry.test.ts +97 -88
  28. package/src/control-plane/registry.ts +142 -110
  29. package/src/control-plane/rollback.ts +8 -38
  30. package/src/control-plane/scheduler.ts +7 -7
  31. package/src/control-plane/secret-audit.test.ts +159 -0
  32. package/src/control-plane/secret-audit.ts +255 -0
  33. package/src/control-plane/secret-mappings.ts +2 -2
  34. package/src/control-plane/secrets-files.test.ts +60 -0
  35. package/src/control-plane/secrets-files.ts +66 -0
  36. package/src/control-plane/secrets.ts +113 -86
  37. package/src/control-plane/setup-config.schema.json +1 -1
  38. package/src/control-plane/setup-status.ts +6 -11
  39. package/src/control-plane/setup.test.ts +42 -40
  40. package/src/control-plane/setup.ts +36 -31
  41. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  42. package/src/control-plane/spec-to-env.test.ts +22 -17
  43. package/src/control-plane/spec-to-env.ts +7 -2
  44. package/src/control-plane/stack-spec.test.ts +10 -0
  45. package/src/control-plane/stack-spec.ts +28 -1
  46. package/src/control-plane/types.ts +2 -4
  47. package/src/control-plane/ui-assets.ts +60 -58
  48. package/src/control-plane/validate.ts +13 -15
  49. package/src/index.ts +47 -15
  50. package/src/control-plane/akm-vault.test.ts +0 -105
  51. package/src/control-plane/akm-vault.ts +0 -311
  52. package/src/control-plane/migrate-0110.test.ts +0 -177
  53. package/src/control-plane/migrate-0110.ts +0 -99
  54. package/src/control-plane/registry-components.test.ts +0 -391
@@ -1,391 +0,0 @@
1
- /**
2
- * Tests for the registry component directory format.
3
- *
4
- * Validates that all components in .openpalm/state/registry/addons/ follow the
5
- * component conventions: compose.yml with required labels, .env.schema
6
- * with documented variables, proper service naming, and no security
7
- * violations.
8
- *
9
- * Two component shapes are accepted:
10
- *
11
- * 1. Full addons — compose.yml + .env.schema. They introduce a new
12
- * service, declare env vars, and must satisfy the full structural
13
- * checklist (labels, network, healthcheck, restart policy, sensitive
14
- * fields).
15
- * 2. Overlay-only addons — compose.yml only. They patch existing
16
- * services (ports, env, volumes) instead of introducing new ones,
17
- * so they have no env vars to document and no service-shaped
18
- * requirements. They still must satisfy the security invariants:
19
- * no INSTANCE_ID, no container_name, no INSTANCE_DIR, no vault
20
- * directory mounts, no docker socket.
21
- */
22
- import { describe, expect, it } from "bun:test";
23
- import {
24
- existsSync,
25
- readdirSync,
26
- readFileSync,
27
- } from "node:fs";
28
- import { join, resolve } from "node:path";
29
-
30
- // ── Helpers ──────────────────────────────────────────────────────────────
31
-
32
- /** Resolve path from repo root */
33
- const REPO_ROOT = resolve(import.meta.dir, "../../../..");
34
- const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/state/registry/addons");
35
-
36
- /** List all component directories in the registry */
37
- function listComponentDirs(): string[] {
38
- if (!existsSync(REGISTRY_DIR)) return [];
39
- return readdirSync(REGISTRY_DIR, { withFileTypes: true })
40
- .filter((d) => d.isDirectory())
41
- .map((d) => d.name);
42
- }
43
-
44
- /** Overlay-only addons ship compose.yml only — no .env.schema. */
45
- function isOverlayOnly(componentId: string): boolean {
46
- return !existsSync(join(REGISTRY_DIR, componentId, ".env.schema"));
47
- }
48
-
49
- function listFullAddonIds(componentIds: string[]): string[] {
50
- return componentIds.filter((id) => !isOverlayOnly(id));
51
- }
52
-
53
- function listOverlayOnlyAddonIds(componentIds: string[]): string[] {
54
- return componentIds.filter(isOverlayOnly);
55
- }
56
-
57
- /** Read a file from a component directory */
58
- function readComponentFile(componentId: string, filename: string): string {
59
- return readFileSync(join(REGISTRY_DIR, componentId, filename), "utf-8");
60
- }
61
-
62
- /** Parse .env.schema into { variable, annotations, defaultValue, comments } entries */
63
- function parseEnvSchema(content: string): Array<{
64
- variable: string;
65
- defaultValue: string;
66
- annotations: string[];
67
- comments: string[];
68
- }> {
69
- const entries: Array<{
70
- variable: string;
71
- defaultValue: string;
72
- annotations: string[];
73
- comments: string[];
74
- }> = [];
75
-
76
- const lines = content.split("\n");
77
- let pendingComments: string[] = [];
78
-
79
- for (const line of lines) {
80
- const trimmed = line.trim();
81
-
82
- if (trimmed.startsWith("#")) {
83
- pendingComments.push(trimmed);
84
- continue;
85
- }
86
-
87
- if (trimmed === "" || trimmed === "---") {
88
- // Blank line or section separator — keep accumulating comments
89
- // for the next variable.
90
- continue;
91
- }
92
-
93
- const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)/);
94
- if (match) {
95
- const variable = match[1];
96
- const defaultValue = match[2];
97
-
98
- // Extract @annotations from pending comments
99
- const annotations: string[] = [];
100
- for (const c of pendingComments) {
101
- const annots = c.match(/@[a-z]+/g);
102
- if (annots) annotations.push(...annots);
103
- }
104
-
105
- entries.push({
106
- variable,
107
- defaultValue,
108
- annotations,
109
- comments: [...pendingComments],
110
- });
111
- pendingComments = [];
112
- }
113
- }
114
-
115
- return entries;
116
- }
117
-
118
- // ── Discovery Tests ──────────────────────────────────────────────────────
119
-
120
- describe("registry component discovery", () => {
121
- const componentIds = listComponentDirs();
122
-
123
- it("finds at least one component in the registry", () => {
124
- expect(componentIds.length).toBeGreaterThan(0);
125
- });
126
-
127
- it("contains the expected core components", () => {
128
- expect(componentIds).toContain("chat");
129
- expect(componentIds).toContain("api");
130
- expect(componentIds).toContain("discord");
131
- expect(componentIds).toContain("slack");
132
- expect(componentIds).toContain("voice");
133
- });
134
-
135
- it("component IDs are valid (lowercase alphanumeric + hyphens)", () => {
136
- const validIdRe = /^[a-z0-9][a-z0-9-]{0,62}$/;
137
- for (const id of componentIds) {
138
- expect(validIdRe.test(id)).toBe(true);
139
- }
140
- });
141
- });
142
-
143
- // ── Required Files Tests ─────────────────────────────────────────────────
144
-
145
- describe("registry component required files", () => {
146
- const componentIds = listComponentDirs();
147
- const fullAddonIds = listFullAddonIds(componentIds);
148
-
149
- for (const id of componentIds) {
150
- it(`${id}: has compose.yml`, () => {
151
- expect(existsSync(join(REGISTRY_DIR, id, "compose.yml"))).toBe(true);
152
- });
153
- }
154
-
155
- for (const id of fullAddonIds) {
156
- it(`${id}: has .env.schema (full addon)`, () => {
157
- expect(existsSync(join(REGISTRY_DIR, id, ".env.schema"))).toBe(true);
158
- });
159
- }
160
- });
161
-
162
- // ── Overlay-only Addon Tests ─────────────────────────────────────────────
163
-
164
- describe("registry overlay-only addons", () => {
165
- const componentIds = listComponentDirs();
166
- const overlayIds = listOverlayOnlyAddonIds(componentIds);
167
-
168
- it("at least one overlay-only addon (ssh) is recognized as valid", () => {
169
- expect(overlayIds).toContain("ssh");
170
- });
171
-
172
- for (const id of overlayIds) {
173
- describe(id, () => {
174
- it("ships only compose.yml (no .env.schema, no entrypoint, no Dockerfile)", () => {
175
- const dirEntries = readdirSync(join(REGISTRY_DIR, id));
176
- // compose.yml is required; an optional README.md is allowed; nothing
177
- // else (no .env.schema, no entrypoint*, no Dockerfile, no scripts).
178
- const allowed = new Set(["compose.yml", "README.md"]);
179
- for (const file of dirEntries) {
180
- expect(allowed.has(file)).toBe(true);
181
- }
182
- });
183
-
184
- it("compose.yml does not introduce a new service (no image: or build:)", () => {
185
- // Overlay-only addons may patch existing services with new ports/env,
186
- // but they MUST NOT introduce a new service that needs its own
187
- // network/healthcheck/restart contract — those would belong in a
188
- // full addon. Reject service definition keys that imply a new
189
- // service body. A pure overlay only sets `ports:`, `environment:`,
190
- // `volumes:`, etc. on already-defined services.
191
- const compose = readComponentFile(id, "compose.yml");
192
- expect(compose).not.toMatch(/^\s+image:\s/m);
193
- expect(compose).not.toMatch(/^\s+build:\s/m);
194
- });
195
- });
196
- }
197
- });
198
-
199
- // ── Compose Overlay Validation Tests ─────────────────────────────────────
200
-
201
- describe("registry compose.yml validation", () => {
202
- const componentIds = listComponentDirs();
203
- const fullAddonIds = listFullAddonIds(componentIds);
204
-
205
- // Full-addon-only assertions: anything that requires a service body
206
- // (labels, network, healthcheck, restart policy) is checked here.
207
- for (const id of fullAddonIds) {
208
- describe(id, () => {
209
- const compose = readComponentFile(id, "compose.yml");
210
-
211
- it("has openpalm.name label", () => {
212
- expect(compose).toMatch(/openpalm\.name:/);
213
- });
214
-
215
- it("has openpalm.description label", () => {
216
- expect(compose).toMatch(/openpalm\.description:/);
217
- });
218
-
219
- it("joins a valid stack network", () => {
220
- const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
221
- expect(hasValidNetwork).toBe(true);
222
- });
223
-
224
- it("has restart policy", () => {
225
- expect(compose).toMatch(/restart:\s/);
226
- });
227
-
228
- it("has healthcheck", () => {
229
- expect(compose).toMatch(/healthcheck:/);
230
- });
231
- });
232
- }
233
-
234
- // Security/hygiene assertions apply to ALL addons (full and overlay-only).
235
- for (const id of componentIds) {
236
- describe(`${id} (security)`, () => {
237
- const compose = readComponentFile(id, "compose.yml");
238
-
239
- it("uses static service name (no INSTANCE_ID)", () => {
240
- expect(compose).not.toContain("${INSTANCE_ID}");
241
- });
242
-
243
- it("does not use container_name", () => {
244
- expect(compose).not.toMatch(/container_name:/);
245
- });
246
-
247
- it("does not reference INSTANCE_DIR", () => {
248
- expect(compose).not.toContain("${INSTANCE_DIR}");
249
- });
250
-
251
- it("does not mount vault directory (single-file mounts allowed)", () => {
252
- // Directory-level vault mounts are a security violation — no container may mount the full vault.
253
- // Single-file mounts like vault/user/ov.conf are allowed (the source must end with a filename).
254
- const lines = compose.split("\n");
255
- for (const line of lines) {
256
- if (line.match(/^\s*-\s+.*vault.*:/)) {
257
- // Extract the source portion (before first colon that follows a path)
258
- const match = line.match(/^\s*-\s+(.+?):/);
259
- if (match) {
260
- const source = match[1];
261
- // Allow single-file vault mounts (path ends with a file, i.e. has an extension or
262
- // a non-directory final segment). Block bare vault/ or vault/<dir>/ mounts.
263
- if (/vault\b/i.test(source) && !/vault\/.*\.[a-z]+$/i.test(source)) {
264
- throw new Error(`Vault directory mount detected: ${line.trim()}`);
265
- }
266
- }
267
- }
268
- }
269
- });
270
-
271
- it("does not mount docker socket", () => {
272
- expect(compose).not.toContain("/var/run/docker.sock");
273
- });
274
-
275
- it("has a comment header describing the component", () => {
276
- expect(compose.startsWith("#")).toBe(true);
277
- });
278
- });
279
- }
280
- });
281
-
282
- // ── .env.schema Validation Tests ─────────────────────────────────────────
283
-
284
- describe("registry .env.schema validation", () => {
285
- const componentIds = listComponentDirs();
286
- const fullAddonIds = listFullAddonIds(componentIds);
287
-
288
- for (const id of fullAddonIds) {
289
- describe(id, () => {
290
- const schema = readComponentFile(id, ".env.schema");
291
- const entries = parseEnvSchema(schema);
292
-
293
- it("is non-empty", () => {
294
- expect(schema.length).toBeGreaterThan(0);
295
- });
296
-
297
- it("has at least one variable definition", () => {
298
- expect(entries.length).toBeGreaterThan(0);
299
- });
300
-
301
- it("does not include INSTANCE_ID (removed)", () => {
302
- const names = entries.map((e) => e.variable);
303
- expect(names).not.toContain("INSTANCE_ID");
304
- });
305
-
306
- it("does not include INSTANCE_DIR (removed)", () => {
307
- const names = entries.map((e) => e.variable);
308
- expect(names).not.toContain("INSTANCE_DIR");
309
- });
310
-
311
- it("has at least one @required variable", () => {
312
- const requiredEntries = entries.filter((e) =>
313
- e.annotations.includes("@required")
314
- );
315
- expect(requiredEntries.length).toBeGreaterThan(0);
316
- });
317
-
318
- it("variable names are valid (uppercase with underscores)", () => {
319
- const validVarRe = /^[A-Z_][A-Z0-9_]*$/;
320
- for (const entry of entries) {
321
- expect(validVarRe.test(entry.variable)).toBe(true);
322
- }
323
- });
324
-
325
- it("every variable has at least one comment line above it", () => {
326
- for (const entry of entries) {
327
- expect(entry.comments.length).toBeGreaterThan(0);
328
- }
329
- });
330
-
331
- it("does not contain vault references", () => {
332
- expect(schema.toLowerCase()).not.toContain("vault/");
333
- });
334
- });
335
- }
336
- });
337
-
338
- // ── Sensitive Fields Tests ───────────────────────────────────────────────
339
-
340
- describe("registry component sensitive fields", () => {
341
- const componentIds = listComponentDirs();
342
- const fullAddonIds = listFullAddonIds(componentIds);
343
-
344
- for (const id of fullAddonIds) {
345
- it(`${id}: has at least one @sensitive field (channel secret)`, () => {
346
- // ollama and voice are local inference servers — no channel secret
347
- // or upstream API key needed (LAN-only, no auth by design).
348
- if (id === "ollama" || id === "voice") return;
349
- const schema = readComponentFile(id, ".env.schema");
350
- const entries = parseEnvSchema(schema);
351
- const sensitiveEntries = entries.filter((e) =>
352
- e.annotations.includes("@sensitive")
353
- );
354
- expect(sensitiveEntries.length).toBeGreaterThan(0);
355
- });
356
- }
357
- });
358
-
359
- // ── Cross-Component Consistency Tests ────────────────────────────────────
360
-
361
- describe("cross-component consistency", () => {
362
- const componentIds = listComponentDirs();
363
- const fullAddonIds = listFullAddonIds(componentIds);
364
-
365
- it("no duplicate openpalm.name labels across full addons", () => {
366
- const names = new Set<string>();
367
- for (const id of fullAddonIds) {
368
- const compose = readComponentFile(id, "compose.yml");
369
- const nameMatch = compose.match(/openpalm\.name:\s*(.+)/);
370
- expect(nameMatch).not.toBeNull();
371
- const name = nameMatch![1].trim();
372
- expect(names.has(name)).toBe(false);
373
- names.add(name);
374
- }
375
- });
376
-
377
- it("all full addons join a valid stack network", () => {
378
- for (const id of fullAddonIds) {
379
- const compose = readComponentFile(id, "compose.yml");
380
- const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
381
- expect(hasValidNetwork).toBe(true);
382
- }
383
- });
384
-
385
- it("no compose file uses INSTANCE_ID anywhere", () => {
386
- for (const id of componentIds) {
387
- const compose = readComponentFile(id, "compose.yml");
388
- expect(compose).not.toContain("INSTANCE_ID");
389
- }
390
- });
391
- });