@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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Provider model discovery and API key resolution.
3
+ *
4
+ * Used by the admin capabilities test endpoint and the CLI setup wizard
5
+ * to enumerate the models a configured provider exposes.
6
+ */
7
+ import { readStackEnv } from "./secrets.js";
8
+ import { PROVIDER_DEFAULT_URLS } from "../provider-constants.js";
9
+
10
+ /** Static model list for Anthropic (no listing API available). */
11
+ const ANTHROPIC_MODELS = [
12
+ "claude-opus-4-6",
13
+ "claude-sonnet-4-6",
14
+ "claude-opus-4-20250514",
15
+ "claude-sonnet-4-20250514",
16
+ "claude-haiku-4-5-20251001",
17
+ "claude-3-5-sonnet-20241022",
18
+ "claude-3-5-haiku-20241022",
19
+ ];
20
+
21
+
22
+ /**
23
+ * Resolve an API key reference.
24
+ *
25
+ * - Empty input → empty string.
26
+ * - `env:NAME` form → looks up `NAME` in `process.env` first, then falls back
27
+ * to `config/stack/stack.env` resolved against `stackDir`.
28
+ * - Anything else → returned verbatim (treated as a literal key value).
29
+ */
30
+ function resolveApiKey(apiKeyRef: string, stackDir: string): string {
31
+ if (!apiKeyRef) return "";
32
+ if (!apiKeyRef.startsWith("env:")) return apiKeyRef;
33
+
34
+ const varName = apiKeyRef.slice(4);
35
+ if (process.env[varName]) return process.env[varName]!;
36
+
37
+ const secrets = readStackEnv(stackDir);
38
+ return secrets[varName] ?? "";
39
+ }
40
+
41
+
42
+ export type ModelDiscoveryReason =
43
+ | 'none'
44
+ | 'provider_static'
45
+ | 'provider_http'
46
+ | 'missing_base_url'
47
+ | 'timeout'
48
+ | 'network';
49
+
50
+ export type ProviderModelsResult = {
51
+ models: string[];
52
+ status: 'ok' | 'recoverable_error';
53
+ reason: ModelDiscoveryReason;
54
+ error?: string;
55
+ };
56
+
57
+ const HTTP_STATUS_LABELS: Record<number, string> = {
58
+ 401: 'Invalid or missing API key',
59
+ 403: 'Access denied — check API key permissions',
60
+ 404: 'Endpoint not found — verify the base URL',
61
+ 429: 'Rate limited — try again shortly',
62
+ 500: 'Provider internal error',
63
+ 502: 'Provider returned a bad gateway error',
64
+ 503: 'Provider is temporarily unavailable',
65
+ };
66
+
67
+ /**
68
+ * Enumerate available models for a provider. Returns an `ok` result with a
69
+ * sorted model list when the provider responds successfully, or a
70
+ * `recoverable_error` with a structured reason otherwise. Network and timeout
71
+ * failures are caught and mapped to a result rather than thrown.
72
+ */
73
+ export async function fetchProviderModels(
74
+ provider: string,
75
+ apiKeyRef: string,
76
+ baseUrl: string,
77
+ stackDir: string
78
+ ): Promise<ProviderModelsResult> {
79
+ try {
80
+ if (provider === "anthropic") {
81
+ return { models: [...ANTHROPIC_MODELS], status: 'ok', reason: 'provider_static' };
82
+ }
83
+
84
+ const resolvedKey = resolveApiKey(apiKeyRef, stackDir);
85
+
86
+ if (provider === "ollama") {
87
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS.ollama;
88
+ const url = `${base.replace(/\/+$/, "")}/api/tags`;
89
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
90
+ if (!res.ok) {
91
+ return {
92
+ models: [],
93
+ status: 'recoverable_error',
94
+ reason: 'provider_http',
95
+ error: `Ollama API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
96
+ };
97
+ }
98
+ const data = (await res.json()) as { models?: { name: string }[] };
99
+ const models = (data.models ?? []).map((m) => m.name).sort();
100
+ return { models, status: 'ok', reason: 'none' };
101
+ }
102
+
103
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS[provider] || "";
104
+ if (!base) {
105
+ return {
106
+ models: [],
107
+ status: 'recoverable_error',
108
+ reason: 'missing_base_url',
109
+ error: `No base URL configured for provider "${provider}"`,
110
+ };
111
+ }
112
+ const url = `${base.replace(/\/+$/, "")}/v1/models`;
113
+
114
+ const headers: Record<string, string> = {};
115
+ if (resolvedKey) {
116
+ headers["Authorization"] = `Bearer ${resolvedKey}`;
117
+ }
118
+
119
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
120
+ if (!res.ok) {
121
+ let detail = '';
122
+ try {
123
+ const json = JSON.parse(await res.text()) as Record<string, unknown>;
124
+ const errObj = json.error as Record<string, unknown> | string | undefined;
125
+ detail = (typeof errObj === 'object' && errObj !== null && typeof errObj.message === 'string') ? errObj.message
126
+ : typeof errObj === 'string' ? errObj
127
+ : typeof json.message === 'string' ? json.message
128
+ : typeof json.detail === 'string' ? json.detail : '';
129
+ } catch { /* ignore parse errors */ }
130
+ return {
131
+ models: [],
132
+ status: 'recoverable_error',
133
+ reason: 'provider_http',
134
+ error: detail
135
+ ? `Provider API returned ${res.status}: ${detail}`
136
+ : `Provider API returned ${res.status}: ${(HTTP_STATUS_LABELS[res.status] ?? `HTTP ${res.status}`)}`,
137
+ };
138
+ }
139
+ const data = (await res.json()) as { data?: { id: string }[] };
140
+ const models = (data.data ?? []).map((m) => m.id).sort();
141
+ return { models, status: 'ok', reason: 'none' };
142
+ } catch (err) {
143
+ const message =
144
+ err instanceof Error && err.name === "TimeoutError"
145
+ ? "Request timed out after 5s"
146
+ : String(err);
147
+ return {
148
+ models: [],
149
+ status: 'recoverable_error',
150
+ reason: err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network',
151
+ error: message,
152
+ };
153
+ }
154
+ }
@@ -1,10 +1,23 @@
1
1
  /**
2
2
  * Tests for the registry component directory format.
3
3
  *
4
- * Validates that all components in .openpalm/registry/addons/ follow the
4
+ * Validates that all components in .openpalm/state/registry/addons/ follow the
5
5
  * component conventions: compose.yml with required labels, .env.schema
6
6
  * with documented variables, proper service naming, and no security
7
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.
8
21
  */
9
22
  import { describe, expect, it } from "bun:test";
10
23
  import {
@@ -18,7 +31,7 @@ import { join, resolve } from "node:path";
18
31
 
19
32
  /** Resolve path from repo root */
20
33
  const REPO_ROOT = resolve(import.meta.dir, "../../../..");
21
- const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/registry/addons");
34
+ const REGISTRY_DIR = join(REPO_ROOT, ".openpalm/state/registry/addons");
22
35
 
23
36
  /** List all component directories in the registry */
24
37
  function listComponentDirs(): string[] {
@@ -28,6 +41,19 @@ function listComponentDirs(): string[] {
28
41
  .map((d) => d.name);
29
42
  }
30
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
+
31
57
  /** Read a file from a component directory */
32
58
  function readComponentFile(componentId: string, filename: string): string {
33
59
  return readFileSync(join(REGISTRY_DIR, componentId, filename), "utf-8");
@@ -118,24 +144,67 @@ describe("registry component discovery", () => {
118
144
 
119
145
  describe("registry component required files", () => {
120
146
  const componentIds = listComponentDirs();
147
+ const fullAddonIds = listFullAddonIds(componentIds);
121
148
 
122
149
  for (const id of componentIds) {
123
150
  it(`${id}: has compose.yml`, () => {
124
151
  expect(existsSync(join(REGISTRY_DIR, id, "compose.yml"))).toBe(true);
125
152
  });
153
+ }
126
154
 
127
- it(`${id}: has .env.schema`, () => {
155
+ for (const id of fullAddonIds) {
156
+ it(`${id}: has .env.schema (full addon)`, () => {
128
157
  expect(existsSync(join(REGISTRY_DIR, id, ".env.schema"))).toBe(true);
129
158
  });
130
159
  }
131
160
  });
132
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
+
133
199
  // ── Compose Overlay Validation Tests ─────────────────────────────────────
134
200
 
135
201
  describe("registry compose.yml validation", () => {
136
202
  const componentIds = listComponentDirs();
203
+ const fullAddonIds = listFullAddonIds(componentIds);
137
204
 
138
- for (const id of componentIds) {
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) {
139
208
  describe(id, () => {
140
209
  const compose = readComponentFile(id, "compose.yml");
141
210
 
@@ -147,18 +216,6 @@ describe("registry compose.yml validation", () => {
147
216
  expect(compose).toMatch(/openpalm\.description:/);
148
217
  });
149
218
 
150
- it("uses static service name (no INSTANCE_ID)", () => {
151
- expect(compose).not.toContain("${INSTANCE_ID}");
152
- });
153
-
154
- it("does not use container_name", () => {
155
- expect(compose).not.toMatch(/container_name:/);
156
- });
157
-
158
- it("does not reference INSTANCE_DIR", () => {
159
- expect(compose).not.toContain("${INSTANCE_DIR}");
160
- });
161
-
162
219
  it("joins a valid stack network", () => {
163
220
  const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
164
221
  expect(hasValidNetwork).toBe(true);
@@ -171,9 +228,28 @@ describe("registry compose.yml validation", () => {
171
228
  it("has healthcheck", () => {
172
229
  expect(compose).toMatch(/healthcheck:/);
173
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
+ });
174
250
 
175
251
  it("does not mount vault directory (single-file mounts allowed)", () => {
176
- // Directory-level vault mounts are a security violation — only admin gets full vault access.
252
+ // Directory-level vault mounts are a security violation — no container may mount the full vault.
177
253
  // Single-file mounts like vault/user/ov.conf are allowed (the source must end with a filename).
178
254
  const lines = compose.split("\n");
179
255
  for (const line of lines) {
@@ -193,8 +269,6 @@ describe("registry compose.yml validation", () => {
193
269
  });
194
270
 
195
271
  it("does not mount docker socket", () => {
196
- // admin component is exempt — docker-socket-proxy IS the docker socket accessor by design
197
- if (id === "admin") return;
198
272
  expect(compose).not.toContain("/var/run/docker.sock");
199
273
  });
200
274
 
@@ -209,8 +283,9 @@ describe("registry compose.yml validation", () => {
209
283
 
210
284
  describe("registry .env.schema validation", () => {
211
285
  const componentIds = listComponentDirs();
286
+ const fullAddonIds = listFullAddonIds(componentIds);
212
287
 
213
- for (const id of componentIds) {
288
+ for (const id of fullAddonIds) {
214
289
  describe(id, () => {
215
290
  const schema = readComponentFile(id, ".env.schema");
216
291
  const entries = parseEnvSchema(schema);
@@ -264,11 +339,13 @@ describe("registry .env.schema validation", () => {
264
339
 
265
340
  describe("registry component sensitive fields", () => {
266
341
  const componentIds = listComponentDirs();
342
+ const fullAddonIds = listFullAddonIds(componentIds);
267
343
 
268
- for (const id of componentIds) {
344
+ for (const id of fullAddonIds) {
269
345
  it(`${id}: has at least one @sensitive field (channel secret)`, () => {
270
- // ollama is a local inference server — no channel secret or API key needed
271
- if (id === "ollama") return;
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;
272
349
  const schema = readComponentFile(id, ".env.schema");
273
350
  const entries = parseEnvSchema(schema);
274
351
  const sensitiveEntries = entries.filter((e) =>
@@ -283,10 +360,11 @@ describe("registry component sensitive fields", () => {
283
360
 
284
361
  describe("cross-component consistency", () => {
285
362
  const componentIds = listComponentDirs();
363
+ const fullAddonIds = listFullAddonIds(componentIds);
286
364
 
287
- it("no duplicate openpalm.name labels across components", () => {
365
+ it("no duplicate openpalm.name labels across full addons", () => {
288
366
  const names = new Set<string>();
289
- for (const id of componentIds) {
367
+ for (const id of fullAddonIds) {
290
368
  const compose = readComponentFile(id, "compose.yml");
291
369
  const nameMatch = compose.match(/openpalm\.name:\s*(.+)/);
292
370
  expect(nameMatch).not.toBeNull();
@@ -296,8 +374,8 @@ describe("cross-component consistency", () => {
296
374
  }
297
375
  });
298
376
 
299
- it("all components join a valid stack network", () => {
300
- for (const id of componentIds) {
377
+ it("all full addons join a valid stack network", () => {
378
+ for (const id of fullAddonIds) {
301
379
  const compose = readComponentFile(id, "compose.yml");
302
380
  const hasValidNetwork = compose.includes("channel_lan") || compose.includes("channel_public") || compose.includes("assistant_net");
303
381
  expect(hasValidNetwork).toBe(true);