@openpalm/lib 0.9.8 → 0.10.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 (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +159 -849
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -1,126 +1,112 @@
1
- /**
2
- * Lifecycle helpers for the OpenPalm control plane.
3
- *
4
- * State factory, apply* lifecycle transitions, compose file list builders,
5
- * and caller/action validation.
6
- *
7
- * All asset operations are delegated via CoreAssetProvider (injected).
8
- */
9
- import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, copyFileSync, mkdtempSync, rmSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { tmpdir } from "node:os";
12
- import { execFile } from "node:child_process";
13
- import { promisify } from "node:util";
1
+ /** Lifecycle helpers — state factory, apply transitions, compose file list. */
2
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
14
3
  import { parseEnvFile, mergeEnvContent } from "./env.js";
15
4
  import type { ControlPlaneState, CallerType } from "./types.js";
16
5
  import { CORE_SERVICES } from "./types.js";
17
- import { resolveConfigHome, resolveStateHome, resolveDataHome } from "./paths.js";
18
- import { loadSecretsEnvFile } from "./secrets.js";
19
- import { stageArtifacts, persistArtifacts, discoverStagedChannelYmls, randomHex, isOllamaEnabled, isAdminEnabled } from "./staging.js";
20
- import { refreshCoreAssets, ensureMemoryDir, ensureCoreAutomations } from "./core-assets.js";
21
- import { ensureMemoryConfig } from "./memory-config.js";
6
+ import {
7
+ resolveOpenPalmHome,
8
+ resolveConfigDir,
9
+ resolveVaultDir,
10
+ resolveDataDir,
11
+ resolveLogsDir,
12
+ resolveCacheHome,
13
+ } from "./home.js";
14
+ import { ensureSecrets, readStackEnv, updateSystemSecretsEnv } from "./secrets.js";
15
+ import {
16
+ resolveRuntimeFiles,
17
+ writeRuntimeFiles,
18
+ randomHex,
19
+ buildEnvFiles,
20
+ discoverStackOverlays,
21
+ } from "./config-persistence.js";
22
+ import { readStackSpec } from "./stack-spec.js";
23
+ import { refreshCoreAssets, ensureMemoryDir } from "./core-assets.js";
22
24
  import { isSetupComplete } from "./setup-status.js";
23
- import type { CoreAssetProvider } from "./core-asset-provider.js";
24
-
25
- const execFileAsync = promisify(execFile);
26
-
27
- /** Resolve the varlock binary path — honours VARLOCK_BIN for dev environments. */
28
- const envVarlockBin = process.env.VARLOCK_BIN;
29
- let VARLOCK_BIN = "varlock";
30
- if (envVarlockBin) {
31
- if (envVarlockBin === "varlock" || envVarlockBin.startsWith("/")) {
32
- VARLOCK_BIN = envVarlockBin;
33
- } else {
34
- console.warn(
35
- `Unsafe VARLOCK_BIN value: ${envVarlockBin}. Falling back to "varlock". ` +
36
- "Must be \"varlock\" or an absolute path.",
37
- );
38
- }
39
- }
25
+ import { snapshotCurrentState } from "./rollback.js";
26
+ import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
27
+ import { acquireLock, releaseLock } from "./lock.js";
28
+ import { listEnabledAddonIds } from "./registry.js";
40
29
 
41
30
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
42
31
  const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
43
32
 
44
- // ── State Factory ──────────────────────────────────────────────────────
45
33
 
46
34
  export function createState(
47
35
  adminToken?: string
48
36
  ): ControlPlaneState {
49
- const stateDir = resolveStateHome();
50
- const configDir = resolveConfigHome();
51
- const fileEnv = loadSecretsEnvFile(configDir);
52
- const resolvedAdminToken =
53
- adminToken ?? fileEnv.OPENPALM_ADMIN_TOKEN ?? fileEnv.ADMIN_TOKEN ?? process.env.OPENPALM_ADMIN_TOKEN ?? process.env.ADMIN_TOKEN ?? "";
37
+ const homeDir = resolveOpenPalmHome();
38
+ const configDir = resolveConfigDir();
39
+ const vaultDir = resolveVaultDir();
40
+ const dataDir = resolveDataDir();
41
+ const logsDir = resolveLogsDir();
42
+ const cacheDir = resolveCacheHome();
54
43
 
55
44
  const services: Record<string, "running" | "stopped"> = {};
56
45
  for (const name of CORE_SERVICES) {
57
46
  services[name] = "stopped";
58
47
  }
59
48
 
60
- const dataDir = resolveDataHome();
61
-
62
- const persistedSecrets = loadPersistedChannelSecrets(dataDir);
63
- const channelSecrets: Record<string, string> = { ...persistedSecrets };
64
-
65
49
  const setupToken = randomHex(16);
66
- const state: ControlPlaneState = {
67
- adminToken: resolvedAdminToken,
50
+ const bootstrapState: ControlPlaneState = {
51
+ adminToken: adminToken ?? process.env.OP_ADMIN_TOKEN ?? "",
52
+ assistantToken: "",
68
53
  setupToken,
69
- stateDir,
54
+ homeDir,
70
55
  configDir,
56
+ vaultDir,
71
57
  dataDir,
58
+ logsDir,
59
+ cacheDir,
72
60
  services,
73
- artifacts: { compose: "", caddyfile: "" },
61
+ artifacts: { compose: "" },
74
62
  artifactMeta: [],
75
63
  audit: [],
76
- channelSecrets
77
64
  };
78
65
 
79
- writeSetupTokenFile(state);
66
+ ensureSecrets(bootstrapState);
80
67
 
81
- return state;
68
+ const stackEnv = readStackEnv(vaultDir);
69
+ // Precedence: explicit parameter > stack.env > process.env.
70
+ bootstrapState.adminToken =
71
+ adminToken
72
+ ?? stackEnv.OP_ADMIN_TOKEN
73
+ ?? process.env.OP_ADMIN_TOKEN
74
+ ?? "";
75
+ bootstrapState.assistantToken =
76
+ stackEnv.OP_ASSISTANT_TOKEN
77
+ ?? process.env.OP_ASSISTANT_TOKEN
78
+ ?? "";
79
+
80
+ writeSetupTokenFile(bootstrapState);
81
+
82
+ return bootstrapState;
82
83
  }
83
84
 
84
- /**
85
- * Write or remove the setup-token.txt file based on setup completion state.
86
- */
87
85
  export function writeSetupTokenFile(state: ControlPlaneState): void {
88
- const tokenPath = `${state.stateDir}/setup-token.txt`;
89
- const setupComplete = isSetupComplete(state.stateDir, state.configDir);
86
+ const tokenPath = `${state.dataDir}/setup-token.txt`;
87
+ const setupComplete = isSetupComplete(state.vaultDir);
90
88
 
91
89
  if (setupComplete) {
92
90
  try { unlinkSync(tokenPath); } catch { /* already gone */ }
93
91
  } else {
94
- mkdirSync(state.stateDir, { recursive: true });
92
+ mkdirSync(state.dataDir, { recursive: true });
95
93
  writeFileSync(tokenPath, state.setupToken + "\n", { mode: 0o600 });
96
94
  }
97
95
  }
98
96
 
99
- // ── Private Loaders ───────────────────────────────────────────────────
100
97
 
101
- function loadPersistedChannelSecrets(dataDir: string): Record<string, string> {
102
- const parsed = parseEnvFile(`${dataDir}/stack.env`);
103
- const result: Record<string, string> = {};
104
- for (const [key, value] of Object.entries(parsed)) {
105
- const match = key.match(/^CHANNEL_([A-Z0-9_]+)_SECRET$/);
106
- if (match?.[1] && value) result[match[1].toLowerCase()] = value;
107
- }
108
- return result;
109
- }
110
-
111
- // ── Lifecycle Helpers ──────────────────────────────────────────────────
112
-
113
- function reconcileCore(
98
+ async function reconcileCore(
114
99
  state: ControlPlaneState,
115
- assets: CoreAssetProvider,
116
- opts: { activateServices?: boolean; deactivateServices?: boolean; seedMemoryConfig?: boolean },
117
- ): string[] {
100
+ opts: { activateServices?: boolean; deactivateServices?: boolean },
101
+ ): Promise<string[]> {
118
102
  if (opts.activateServices) {
119
103
  for (const s of CORE_SERVICES) state.services[s] = "running";
120
104
  }
121
- ensureMemoryDir();
122
- ensureCoreAutomations(assets);
123
- if (opts.seedMemoryConfig) ensureMemoryConfig(state.dataDir);
105
+ ensureMemoryDir(state.dataDir);
106
+
107
+ for (const addonName of listEnabledAddonIds(state.homeDir)) {
108
+ mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
109
+ }
124
110
 
125
111
  const active: string[] = [];
126
112
  for (const [name, status] of Object.entries(state.services)) {
@@ -131,21 +117,69 @@ function reconcileCore(
131
117
  for (const name of Object.keys(state.services)) state.services[name] = "stopped";
132
118
  }
133
119
 
134
- state.artifacts = stageArtifacts(state, assets);
135
- persistArtifacts(state, assets);
120
+ // Preflight: validate compose merge before mutation.
121
+ // Mandatory when compose files exist and OP_SKIP_COMPOSE_PREFLIGHT is not set.
122
+ // Fails if Docker is unavailable (Docker is required for any compose operation).
123
+ const files = buildComposeFileList(state);
124
+ const envFiles = buildEnvFiles(state);
125
+ if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
126
+ const dockerCheck = await checkDocker();
127
+ if (!dockerCheck.ok) {
128
+ throw new Error(
129
+ "Compose preflight failed: Docker is not available.\n" +
130
+ "Docker must be running before install/update/apply operations."
131
+ );
132
+ }
133
+ const preflight = await composePreflight({ files, envFiles });
134
+ if (!preflight.ok) {
135
+ const projectName = resolveComposeProjectName();
136
+ const fileArgs = files.flatMap((f) => ["-f", f]).join(" ");
137
+ const envArgs = envFiles.filter(existsSync).flatMap((f) => ["--env-file", f]).join(" ");
138
+ const resolvedCmd = `docker compose ${fileArgs} --project-name ${projectName} ${envArgs} config --quiet`;
139
+ throw new Error(
140
+ `Compose preflight failed: ${preflight.stderr}\n` +
141
+ `Resolved command: ${resolvedCmd}\n` +
142
+ `Files: ${files.join(", ")}\n` +
143
+ `Env files: ${envFiles.join(", ")}\n` +
144
+ `Project: ${projectName}`
145
+ );
146
+ }
147
+ }
148
+
149
+ // Snapshot before writing (for rollback on failure)
150
+ snapshotCurrentState(state);
151
+
152
+ // Resolve and write runtime files to live paths
153
+ state.artifacts = resolveRuntimeFiles();
154
+ writeRuntimeFiles(state);
136
155
  return active;
137
156
  }
138
157
 
139
- export function applyInstall(state: ControlPlaneState, assets: CoreAssetProvider): void {
140
- reconcileCore(state, assets, { activateServices: true, seedMemoryConfig: true });
158
+ export async function applyInstall(state: ControlPlaneState): Promise<void> {
159
+ const lock = acquireLock(state.homeDir, "install");
160
+ try {
161
+ await reconcileCore(state, { activateServices: true });
162
+ } finally {
163
+ releaseLock(lock);
164
+ }
141
165
  }
142
166
 
143
- export function applyUpdate(state: ControlPlaneState, assets: CoreAssetProvider): { restarted: string[] } {
144
- return { restarted: reconcileCore(state, assets, {}) };
167
+ export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
168
+ const lock = acquireLock(state.homeDir, "update");
169
+ try {
170
+ return { restarted: await reconcileCore(state, {}) };
171
+ } finally {
172
+ releaseLock(lock);
173
+ }
145
174
  }
146
175
 
147
- export function applyUninstall(state: ControlPlaneState, assets: CoreAssetProvider): { stopped: string[] } {
148
- return { stopped: reconcileCore(state, assets, { deactivateServices: true }) };
176
+ export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
177
+ const lock = acquireLock(state.homeDir, "uninstall");
178
+ try {
179
+ return { stopped: await reconcileCore(state, { deactivateServices: true }) };
180
+ } finally {
181
+ releaseLock(lock);
182
+ }
149
183
  }
150
184
 
151
185
  type DockerTagEntry = { name?: unknown };
@@ -169,12 +203,12 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
169
203
  namespace: string;
170
204
  tag: string;
171
205
  }> {
172
- const stackEnvPath = `${state.dataDir}/stack.env`;
173
- const parsed = parseEnvFile(stackEnvPath);
174
- const namespace = (parsed.OPENPALM_IMAGE_NAMESPACE ?? process.env.OPENPALM_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
206
+ const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
207
+ const parsed = parseEnvFile(systemEnvPath);
208
+ const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
175
209
 
176
210
  if (!IMAGE_NAMESPACE_RE.test(namespace)) {
177
- throw new Error(`Invalid image namespace in stack.env: ${namespace}`);
211
+ throw new Error(`Invalid image namespace in system.env: ${namespace}`);
178
212
  }
179
213
 
180
214
  let response: Response;
@@ -197,72 +231,124 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
197
231
  throw new Error("No usable Docker image tag found");
198
232
  }
199
233
 
200
- const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
201
- const updatedContent = mergeEnvContent(currentContent, { OPENPALM_IMAGE_TAG: latestTag }, { uncomment: true });
202
- writeFileSync(stackEnvPath, updatedContent);
234
+ const currentContent = existsSync(systemEnvPath) ? readFileSync(systemEnvPath, "utf-8") : "";
235
+ const updatedContent = mergeEnvContent(currentContent, { OP_IMAGE_TAG: latestTag }, { uncomment: true });
236
+ writeFileSync(systemEnvPath, updatedContent);
203
237
 
204
238
  return { namespace, tag: latestTag };
205
239
  }
206
240
 
207
241
  export async function applyUpgrade(
208
- state: ControlPlaneState,
209
- assets: CoreAssetProvider
242
+ state: ControlPlaneState
210
243
  ): Promise<{
211
244
  backupDir: string | null;
212
245
  updated: string[];
213
246
  restarted: string[];
214
247
  }> {
215
- const { backupDir, updated } = await refreshCoreAssets();
216
- const restarted = reconcileCore(state, assets, {});
217
- return { backupDir, updated, restarted };
248
+ const lock = acquireLock(state.homeDir, "upgrade");
249
+ try {
250
+ const { backupDir, updated } = await refreshCoreAssets();
251
+ const restarted = await reconcileCore(state, {});
252
+ return { backupDir, updated, restarted };
253
+ } finally {
254
+ releaseLock(lock);
255
+ }
218
256
  }
219
257
 
220
- // ── Compose File List Builder ────────────────────────────────────────────
258
+ export type UpgradeResult = {
259
+ imageTag: string;
260
+ namespace: string;
261
+ backupDir: string | null;
262
+ assetsUpdated: string[];
263
+ restarted: string[];
264
+ };
221
265
 
222
- export function buildComposeFileList(state: ControlPlaneState): string[] {
223
- const files = [`${state.stateDir}/artifacts/docker-compose.yml`];
266
+ /**
267
+ * Full upgrade: resolve latest image tag, refresh assets, pull images,
268
+ * and recreate containers. Used by both the admin endpoint and CLI.
269
+ *
270
+ * Callers handle their own audit logging and admin self-recreation.
271
+ */
272
+ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeResult> {
273
+ const files = buildComposeFileList(state);
274
+ const envFiles = buildEnvFiles(state);
275
+
276
+ // 1. Preflight: validate compose merge before any mutation
277
+ if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
278
+ const preflight = await composePreflight({ files, envFiles });
279
+ if (!preflight.ok) {
280
+ throw new Error(`Compose preflight failed: ${preflight.stderr}`);
281
+ }
282
+ }
283
+
284
+ // 2. Snapshot stack.env for rollback on failure
285
+ const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
286
+ let originalStackEnv: string | null = null;
287
+ try {
288
+ originalStackEnv = readFileSync(stackEnvPath, "utf-8");
289
+ } catch { /* stack.env may not exist yet */ }
224
290
 
225
- if (isAdminEnabled(state)) {
226
- const adminYml = `${state.stateDir}/artifacts/admin.yml`;
227
- files.push(adminYml);
291
+ // 3. Update image tag + refresh core assets
292
+ let imageTag: string;
293
+ let namespace: string;
294
+ let upgradeResult: { backupDir: string | null; updated: string[]; restarted: string[] };
295
+ try {
296
+ const tagResult = await updateStackEnvToLatestImageTag(state);
297
+ imageTag = tagResult.tag;
298
+ namespace = tagResult.namespace;
299
+ upgradeResult = await applyUpgrade(state);
300
+ } catch (e) {
301
+ // Restore stack.env on failure
302
+ if (originalStackEnv !== null) {
303
+ try { writeFileSync(stackEnvPath, originalStackEnv); } catch { /* best effort */ }
304
+ }
305
+ throw e;
228
306
  }
229
307
 
230
- if (isOllamaEnabled(state)) {
231
- const ollamaYml = `${state.stateDir}/artifacts/ollama.yml`;
232
- files.push(ollamaYml);
308
+ // 4. Pull images
309
+ const pullResult = await composePull({ files, envFiles });
310
+ if (!pullResult.ok) {
311
+ throw new Error(`Failed to pull images: ${pullResult.stderr}`);
233
312
  }
234
313
 
235
- const stagedYmls = discoverStagedChannelYmls(state.stateDir);
236
- files.push(...stagedYmls);
314
+ // 5. Recreate containers
315
+ const services = await buildManagedServices(state);
316
+ const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
317
+ if (!upResult.ok) {
318
+ throw new Error(`Images pulled but failed to recreate containers: ${upResult.stderr}`);
319
+ }
237
320
 
238
- return files;
321
+ return {
322
+ imageTag,
323
+ namespace,
324
+ backupDir: upgradeResult.backupDir,
325
+ assetsUpdated: upgradeResult.updated,
326
+ restarted: upgradeResult.restarted,
327
+ };
239
328
  }
240
329
 
241
- /**
242
- * Build the list of services that `docker compose up` should manage.
243
- * Core services always; admin/caddy/docker-socket-proxy only when admin is enabled.
244
- */
245
- export function buildManagedServices(state: ControlPlaneState): string[] {
246
- const services: string[] = [...CORE_SERVICES];
330
+ export function buildComposeFileList(state: ControlPlaneState): string[] {
331
+ return discoverStackOverlays(`${state.homeDir}/stack`);
332
+ }
247
333
 
248
- if (isAdminEnabled(state)) {
249
- services.push("caddy", "admin", "docker-socket-proxy");
250
- }
334
+ export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
335
+ const files = buildComposeFileList(state);
336
+ const envFiles = buildEnvFiles(state);
251
337
 
252
- if (isOllamaEnabled(state)) {
253
- services.push("ollama");
338
+ // Prefer compose-derived service list when Docker is available
339
+ if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
340
+ const result = await composeConfigServices({ files, envFiles });
341
+ if (result.ok && result.services.length > 0) {
342
+ return result.services;
343
+ }
254
344
  }
255
345
 
256
- const stagedYmls = discoverStagedChannelYmls(state.stateDir);
257
- for (const p of stagedYmls) {
258
- const filename = p.split("/").pop() ?? "";
259
- const name = filename.replace(/\.yml$/, "");
260
- if (name) services.push(`channel-${name}`);
261
- }
346
+ // Fallback: static inference from CORE_SERVICES + active addon overlays
347
+ const services: string[] = [...CORE_SERVICES];
348
+ services.push(...listEnabledAddonIds(state.homeDir));
262
349
  return services;
263
350
  }
264
351
 
265
- // ── Caller Normalization ───────────────────────────────────────────────
266
352
 
267
353
  const VALID_CALLERS = new Set<CallerType>([
268
354
  "assistant",
@@ -276,107 +362,3 @@ export function normalizeCaller(headerValue: string | null): CallerType {
276
362
  const v = (headerValue ?? "").trim().toLowerCase() as CallerType;
277
363
  return VALID_CALLERS.has(v) ? v : "unknown";
278
364
  }
279
-
280
- // ── Action Validation ──────────────────────────────────────────────────
281
-
282
- const ALLOWED_ACTIONS = new Set([
283
- "install",
284
- "update",
285
- "upgrade",
286
- "uninstall",
287
- "containers.list",
288
- "containers.up",
289
- "containers.down",
290
- "containers.restart",
291
- "channels.list",
292
- "channels.install",
293
- "channels.uninstall",
294
-
295
- "extensions.list",
296
- "artifacts.list",
297
- "artifacts.get",
298
- "artifacts.manifest",
299
- "audit.list",
300
- "accessScope.get",
301
- "accessScope.set",
302
- "connections.get",
303
- "connections.patch",
304
- "connections.status"
305
- ]);
306
-
307
- export function isAllowedAction(action: string): boolean {
308
- return ALLOWED_ACTIONS.has(action);
309
- }
310
-
311
- // ── Environment Validation ─────────────────────────────────────────────
312
-
313
- export async function validateEnvironment(state: ControlPlaneState): Promise<{
314
- ok: boolean;
315
- errors: string[];
316
- warnings: string[];
317
- }> {
318
- const schemaPath = `${state.dataDir}/secrets.env.schema`;
319
- const envPath = `${state.configDir}/secrets.env`;
320
-
321
- function sanitizeVarlockMessage(msg: string): string {
322
- return msg
323
- .replace(/sk-[A-Za-z0-9]{20,}/g, "[REDACTED]")
324
- .replace(/gsk_[A-Za-z0-9]{30,}/g, "[REDACTED]")
325
- .replace(/AIza[A-Za-z0-9_\-]{35}/g, "[REDACTED]")
326
- .replace(/[0-9a-f]{32,}/gi, "[REDACTED]")
327
- .replace(/value '([^']*)'/g, "value '[REDACTED]'");
328
- }
329
-
330
- function collectVarlockOutput(stderr: string, errors: string[], warnings: string[]): void {
331
- for (const line of stderr.split("\n")) {
332
- const trimmed = sanitizeVarlockMessage(line.trim());
333
- if (!trimmed) continue;
334
- if (trimmed.includes("ERROR")) errors.push(trimmed);
335
- else if (trimmed.includes("WARN")) warnings.push(trimmed);
336
- }
337
- }
338
-
339
- async function runVarlockLoad(
340
- schemaFile: string,
341
- envFile: string,
342
- ): Promise<void> {
343
- const tmpDir = mkdtempSync(join(tmpdir(), "varlock-"));
344
- try {
345
- copyFileSync(schemaFile, join(tmpDir, ".env.schema"));
346
- copyFileSync(envFile, join(tmpDir, ".env"));
347
- await execFileAsync(
348
- VARLOCK_BIN,
349
- ["load", "--path", `${tmpDir}/`],
350
- { timeout: 10000 }
351
- );
352
- } finally {
353
- rmSync(tmpDir, { recursive: true, force: true });
354
- }
355
- }
356
-
357
- const errors: string[] = [];
358
- const warnings: string[] = [];
359
- let anyFailed = false;
360
-
361
- try {
362
- await runVarlockLoad(schemaPath, envPath);
363
- } catch (err: unknown) {
364
- anyFailed = true;
365
- if (err && typeof err === "object" && "stderr" in err) {
366
- collectVarlockOutput(String((err as { stderr: string }).stderr), errors, warnings);
367
- }
368
- }
369
-
370
- const stackSchemaPath = `${state.dataDir}/stack.env.schema`;
371
- const stackEnvPath = `${state.stateDir}/artifacts/stack.env`;
372
- try {
373
- await runVarlockLoad(stackSchemaPath, stackEnvPath);
374
- } catch (err: unknown) {
375
- anyFailed = true;
376
- if (err && typeof err === "object" && "stderr" in err) {
377
- collectVarlockOutput(String((err as { stderr: string }).stderr), errors, warnings);
378
- }
379
- }
380
-
381
- return { ok: !anyFailed && errors.length === 0, errors, warnings };
382
- }