@openpalm/lib 0.11.0-beta.3 → 0.11.0-beta.6

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.
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Shared control-plane library for OpenPalm.
4
4
  CLI, admin, and scheduler use this package so stack behavior stays consistent.
5
5
 
6
+ > **Bun required.** This package ships TypeScript source and relies on Bun's native TS execution. It does not compile to JavaScript and is not compatible with Node.js.
7
+
6
8
  The current model is direct-write over `~/.openpalm/` plus native Docker Compose.
7
9
  Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
8
10
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-beta.3",
3
+ "version": "0.11.0-beta.6",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
7
+ "engines": {
8
+ "bun": ">=1.0.0"
9
+ },
7
10
  "scripts": {
8
11
  "test": "bun test"
9
12
  },
@@ -244,7 +244,11 @@ export async function deleteAkmVaultKey(
244
244
  const vaultPath = await ensureAkmUserVault(state, env);
245
245
  if (!vaultPath) return false;
246
246
  try {
247
- await execAkm(["vault", "unset", AKM_USER_VAULT_REF, key], env);
247
+ // --yes: newer akm versions require explicit confirmation for any
248
+ // destructive operation in non-interactive mode. Without this flag
249
+ // the command exits with NON_INTERACTIVE_REQUIRES_YES and our
250
+ // delete looks like a hard failure instead of an idempotent unset.
251
+ await execAkm(["vault", "unset", "--yes", AKM_USER_VAULT_REF, key], env);
248
252
  } catch (err) {
249
253
  // `unset` of a missing key is a benign no-op; many akm versions exit 0
250
254
  // anyway. If akm hard-fails (non-zero, non-empty stderr) we surface it.
@@ -19,9 +19,11 @@ function isValidChannelName(name: string): boolean {
19
19
  // ── Channel Discovery ─────────────────────────────────────────────────
20
20
 
21
21
  /**
22
- * Check if a compose file defines a channel service (has CHANNEL_NAME or GUARDIAN_URL).
23
- * This is compose-derived: we parse the actual compose content rather than
24
- * relying on filename patterns or directory naming conventions.
22
+ * Check if a compose file defines a channel service (has CHANNEL_NAME).
23
+ * Compose-derived: we parse the actual compose content rather than rely on
24
+ * filename or directory naming conventions. (GUARDIAN_URL used to be a
25
+ * fallback signal — it's been removed since channels-sdk now hardcodes the
26
+ * in-network guardian URL.)
25
27
  */
26
28
  export function isChannelAddon(composePath: string): boolean {
27
29
  try {
@@ -36,9 +38,9 @@ export function isChannelAddon(composePath: string): boolean {
36
38
  const env = (svcDef as Record<string, unknown>).environment;
37
39
  if (typeof env === "object" && env !== null) {
38
40
  if (Array.isArray(env)) {
39
- if (env.some((e: unknown) => typeof e === "string" && (e.startsWith("CHANNEL_NAME=") || e.startsWith("GUARDIAN_URL=")))) return true;
41
+ if (env.some((e: unknown) => typeof e === "string" && e.startsWith("CHANNEL_NAME="))) return true;
40
42
  } else {
41
- if ("CHANNEL_NAME" in (env as Record<string, unknown>) || "GUARDIAN_URL" in (env as Record<string, unknown>)) return true;
43
+ if ("CHANNEL_NAME" in (env as Record<string, unknown>)) return true;
42
44
  }
43
45
  }
44
46
  }
@@ -51,7 +53,7 @@ export function isChannelAddon(composePath: string): boolean {
51
53
  /**
52
54
  * Discover installed channels by scanning stack/addons/ for channel addons.
53
55
  * A channel addon is identified by compose-derived truth: its compose.yml
54
- * defines services with CHANNEL_NAME or GUARDIAN_URL environment variables.
56
+ * defines services with a CHANNEL_NAME environment variable.
55
57
  *
56
58
  * Non-channel addons (admin, ollama, etc.) are excluded.
57
59
  *
@@ -6,7 +6,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
  import {
9
- COMPOSE_PROJECT_NAME,
10
9
  buildComposeOptions,
11
10
  buildComposeCliArgs,
12
11
  } from "./compose-args.js";
@@ -63,14 +62,6 @@ afterEach(() => {
63
62
  rmSync(tempDir, { recursive: true, force: true });
64
63
  });
65
64
 
66
- // ── COMPOSE_PROJECT_NAME ─────────────────────────────────────────────────
67
-
68
- describe("COMPOSE_PROJECT_NAME", () => {
69
- it("is 'openpalm'", () => {
70
- expect(COMPOSE_PROJECT_NAME).toBe("openpalm");
71
- });
72
- });
73
-
74
65
  // ── buildComposeOptions ──────────────────────────────────────────────────
75
66
 
76
67
  describe("buildComposeOptions", () => {
@@ -11,10 +11,6 @@ import { buildComposeFileList } from "./lifecycle.js";
11
11
  import { buildEnvFiles } from "./config-persistence.js";
12
12
  import { resolveComposeProjectName } from "./docker.js";
13
13
 
14
- // ── Constants ────────────────────────────────────────────────────────────
15
-
16
- export const COMPOSE_PROJECT_NAME = "openpalm";
17
-
18
14
  // ── Types ────────────────────────────────────────────────────────────────
19
15
 
20
16
  export type ComposeOptions = {
@@ -12,6 +12,7 @@ import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
12
12
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
13
13
  import { isChannelAddon } from "./channels.js";
14
14
  import { listEnabledAddonIds } from "./registry.js";
15
+ import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
15
16
 
16
17
  import {
17
18
  readCoreCompose,
@@ -69,6 +70,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
69
70
  OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
70
71
  };
71
72
 
73
+ // Backfill OP_UID/OP_GID when the existing stack.env was written by an
74
+ // older code path that hard-coded 1000, or when the file was created
75
+ // with missing/zero values. We only override when the current value is
76
+ // missing or zero — an operator who manually set OP_UID=2000 (e.g.
77
+ // because they're running on a host with a non-1000 service account)
78
+ // must not be silently changed.
79
+ const parsed = parseEnvFile(systemEnvPath);
80
+ const ids = resolveOperatorIds(state.homeDir);
81
+ if (ids) {
82
+ if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
83
+ if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
84
+ }
85
+
72
86
  const content = mergeEnvContent(base, adminManaged, {
73
87
  sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
74
88
  });
@@ -77,8 +91,13 @@ export function writeSystemEnv(state: ControlPlaneState): void {
77
91
  }
78
92
 
79
93
  function generateFallbackSystemEnv(state: ControlPlaneState): string {
80
- const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
81
- const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
94
+ // Operator UID/GID auto-detect from OP_HOME owner (or process UID).
95
+ // Skipped on Windows where containers run in WSL2 and OP_UID has no
96
+ // meaning on the host process.
97
+ const ids = resolveOperatorIds(state.homeDir);
98
+ const idLines: string[] = ids
99
+ ? [`OP_UID=${ids.uid}`, `OP_GID=${ids.gid}`]
100
+ : [];
82
101
 
83
102
  return [
84
103
  "# OpenPalm — System Configuration (managed by CLI/admin)",
@@ -92,18 +111,18 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
92
111
  "",
93
112
  "# ── Paths ──────────────────────────────────────────────────────────",
94
113
  `OP_HOME=${state.homeDir}`,
95
- `OP_UID=${uid}`,
96
- `OP_GID=${gid}`,
114
+ ...idLines,
97
115
  "",
98
116
  "# ── Images ──────────────────────────────────────────────────────────",
99
117
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
100
118
  `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
101
119
  "",
102
120
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
121
+ "# Guardian is network-only (no host port) — channels reach it via",
122
+ "# http://guardian:8080 over the channel_lan Docker network.",
103
123
  `OP_ASSISTANT_PORT=3800`,
104
124
  `OP_ADMIN_PORT=3880`,
105
125
  `OP_ADMIN_OPENCODE_PORT=3881`,
106
- `OP_GUARDIAN_PORT=3899`,
107
126
  ""
108
127
  ].join("\n");
109
128
  }
@@ -126,8 +145,21 @@ export function discoverStackOverlays(stackDir: string): string[] {
126
145
  .filter((e) => e.isDirectory())
127
146
  .sort((a, b) => a.name.localeCompare(b.name));
128
147
  for (const entry of entries) {
129
- const addonCompose = `${addonsDir}/${entry.name}/compose.yml`;
130
- if (existsSync(addonCompose)) files.push(addonCompose);
148
+ const dir = `${addonsDir}/${entry.name}`;
149
+ // Pick up compose.yml plus any compose.<variant>.yml sibling
150
+ // overlays (e.g. compose.cdi.yml generated by /admin/voice on
151
+ // CDI hosts). Stable sort: compose.yml first, then siblings
152
+ // alphabetically, so the base file's keys are the defaults and
153
+ // overlays merge on top in deterministic order.
154
+ const overlays = readdirSync(dir, { withFileTypes: true })
155
+ .filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
156
+ .map((e) => e.name)
157
+ .sort((a, b) => {
158
+ if (a === "compose.yml" || a === "compose.yaml") return -1;
159
+ if (b === "compose.yml" || b === "compose.yaml") return 1;
160
+ return a.localeCompare(b);
161
+ });
162
+ for (const name of overlays) files.push(`${dir}/${name}`);
131
163
  }
132
164
  }
133
165
 
@@ -225,7 +257,7 @@ export function writeChannelSecrets(stackDir: string, secrets: Record<string, st
225
257
  * (e.g. `/var/run/docker.sock`) are left alone.
226
258
  */
227
259
  export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
228
- const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
260
+ const composeFiles = discoverStackOverlays(state.stackDir);
229
261
  if (composeFiles.length === 0) return;
230
262
 
231
263
  const envVars: Record<string, string> = {
@@ -281,9 +313,13 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
281
313
  export function writeRuntimeFiles(
282
314
  state: ControlPlaneState
283
315
  ): void {
284
- // Write core compose to config/stack/
316
+ // Write core compose to config/stack/ only on first install —
317
+ // refreshCoreAssets() is the canonical writer on update.
285
318
  mkdirSync(state.stackDir, { recursive: true });
286
- writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
319
+ const composePath = `${state.stackDir}/core.compose.yml`;
320
+ if (!existsSync(composePath)) {
321
+ writeFileSync(composePath, state.artifacts.compose);
322
+ }
287
323
 
288
324
  // Load persisted channel HMAC secrets from guardian.env,
289
325
  // then generate new ones for new channel addons.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
13
13
  import { dirname, join, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
15
16
  import { createLogger } from "../logger.js";
16
17
  import { sha256 } from "./crypto.js";
@@ -80,15 +81,26 @@ export function seedStashAssets(seeds: Record<string, string>): string[] {
80
81
  // ── Asset Refresh (GitHub download) ──────────────────────────────────
81
82
 
82
83
  const REPO = "itlackey/openpalm";
83
- const VERSION = process.env.OP_ASSET_VERSION ?? "main";
84
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).
85
+ function resolveAssetVersion(): string {
86
+ if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
87
+ try {
88
+ const pkgJson = JSON.parse(
89
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
90
+ );
91
+ return `v${pkgJson.version}`;
92
+ } catch {
93
+ return "main";
94
+ }
95
+ }
96
+ const VERSION = resolveAssetVersion();
97
+
98
+ // Persona files (openpalm.md, system.md) and stash seeds are intentionally NOT
99
+ // in this list — they are user-customizable and use seedAssistantPersonaFiles()
100
+ // / seedStashAssets() which never overwrite existing files (user edits win).
87
101
  const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
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" },
102
+ { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
103
+ { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
92
104
  ];
93
105
 
94
106
  async function downloadAsset(filename: string): Promise<string> {
@@ -139,3 +151,32 @@ export async function refreshCoreAssets(): Promise<{
139
151
 
140
152
  return { backupDir, updated };
141
153
  }
154
+
155
+ // ── Assistant Persona File Seeding ────────────────────────────────────
156
+
157
+ /**
158
+ * Seed assistant persona files (openpalm.md, system.md) into OP_HOME.
159
+ *
160
+ * Idempotent: **never overwrites** an existing file — user edits always
161
+ * win. This preserves the "config/ is user-owned" contract: persona files
162
+ * are seeded once on first install and never touched again on update.
163
+ *
164
+ * `seeds` maps relative path keys (e.g. `"config/assistant/openpalm.md"`)
165
+ * to file content. Each file is written to `resolveOpenPalmHome()/<relPath>`
166
+ * only if the file does not already exist.
167
+ *
168
+ * Returns the list of relative paths that were actually written (empty on
169
+ * re-run when every seed already exists on disk).
170
+ */
171
+ export function seedAssistantPersonaFiles(seeds: Record<string, string>): string[] {
172
+ const homeDir = resolveOpenPalmHome();
173
+ const written: string[] = [];
174
+ for (const [relPath, content] of Object.entries(seeds)) {
175
+ const targetPath = join(homeDir, relPath);
176
+ if (existsSync(targetPath)) continue;
177
+ mkdirSync(dirname(targetPath), { recursive: true });
178
+ writeFileSync(targetPath, content);
179
+ written.push(relPath);
180
+ }
181
+ return written;
182
+ }
@@ -295,26 +295,37 @@ export async function composeLogs(
295
295
  return run(args, undefined);
296
296
  }
297
297
 
298
+ // 60-minute pull timeout. Voice addon ships a ~2.4 GB image (CPU) /
299
+ // ~7.6 GB (CUDA); on a 1-2 Mbps home connection these legitimately take
300
+ // 30+ minutes. The previous 5-min cap silently killed pulls mid-stream
301
+ // on first install, surfacing as an opaque "pull failed". The wizard's
302
+ // retry layer wraps this, so an actually-hung pull is bounded by the
303
+ // outer retry budget; this just gives any progressing pull room to
304
+ // finish on slow connections.
305
+ const PULL_TIMEOUT_MS = 60 * 60_000;
306
+
298
307
  /**
299
308
  * Pull image for a single service.
300
309
  */
301
310
  export async function composePullService(
302
311
  service: string,
303
- options: { files: string[]; envFiles?: string[] }
312
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
304
313
  ): Promise<DockerResult> {
305
314
  await runPreflight(options);
306
315
  const args = buildComposeArgs(options);
316
+ for (const p of options.profiles ?? []) args.push("--profile", p);
307
317
  args.push("pull", service);
308
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
318
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
309
319
  }
310
320
 
311
321
  export async function composePull(
312
- options: { files: string[]; envFiles?: string[] }
322
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
313
323
  ): Promise<DockerResult> {
314
324
  await runPreflight(options);
315
325
  const args = buildComposeArgs(options);
326
+ for (const p of options.profiles ?? []) args.push("--profile", p);
316
327
  args.push("pull");
317
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
328
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
318
329
  }
319
330
 
320
331
  /**
@@ -142,8 +142,8 @@ function seedMinimalEnvFiles(): void {
142
142
  "GROQ_API_KEY=",
143
143
  "MISTRAL_API_KEY=",
144
144
  "GOOGLE_API_KEY=",
145
- "OWNER_NAME=",
146
- "OWNER_EMAIL=",
145
+ "OP_OWNER_NAME=",
146
+ "OP_OWNER_EMAIL=",
147
147
  "",
148
148
  ].join("\n")
149
149
  );
@@ -187,7 +187,7 @@ describe("Fresh Install", () => {
187
187
  // API keys and owner info are seeded in state/stack.env.
188
188
  const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
189
189
  expect(stackContent).toContain("OPENAI_API_KEY=");
190
- expect(stackContent).toContain("OWNER_NAME=");
190
+ expect(stackContent).toContain("OP_OWNER_NAME=");
191
191
  });
192
192
 
193
193
  // Scenario 2: isSetupComplete returns false before setup
@@ -25,7 +25,7 @@ import { refreshCoreAssets } from "./core-assets.js";
25
25
  import { isSetupComplete } from "./setup-status.js";
26
26
  import { snapshotCurrentState } from "./rollback.js";
27
27
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
28
- import { acquireLock, releaseLock } from "./lock.js";
28
+ import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
29
29
  import { listEnabledAddonIds } from "./registry.js";
30
30
 
31
31
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
@@ -125,7 +125,8 @@ async function reconcileCore(
125
125
  }
126
126
 
127
127
  export async function applyInstall(state: ControlPlaneState): Promise<void> {
128
- const lock = acquireLock(state.homeDir, "install");
128
+ const lock = acquireInstallLock(state.stateDir);
129
+ if (!lock) throw new Error("Another install is already in progress");
129
130
  try {
130
131
  await reconcileCore(state, { activateServices: true });
131
132
  // Pre-create host-side volume mount targets as the current user so
@@ -133,25 +134,27 @@ export async function applyInstall(state: ControlPlaneState): Promise<void> {
133
134
  // non-root containers).
134
135
  ensureComposeVolumeTargets(state);
135
136
  } finally {
136
- releaseLock(lock);
137
+ releaseInstallLock(lock);
137
138
  }
138
139
  }
139
140
 
140
141
  export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
141
- const lock = acquireLock(state.homeDir, "update");
142
+ const lock = acquireInstallLock(state.stateDir);
143
+ if (!lock) throw new Error("Another install is already in progress");
142
144
  try {
143
145
  return { restarted: await reconcileCore(state, {}) };
144
146
  } finally {
145
- releaseLock(lock);
147
+ releaseInstallLock(lock);
146
148
  }
147
149
  }
148
150
 
149
151
  export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
150
- const lock = acquireLock(state.homeDir, "uninstall");
152
+ const lock = acquireInstallLock(state.stateDir);
153
+ if (!lock) throw new Error("Another install is already in progress");
151
154
  try {
152
155
  return { stopped: await reconcileCore(state, { deactivateServices: true }) };
153
156
  } finally {
154
- releaseLock(lock);
157
+ releaseInstallLock(lock);
155
158
  }
156
159
  }
157
160
 
@@ -218,13 +221,14 @@ export async function applyUpgrade(
218
221
  updated: string[];
219
222
  restarted: string[];
220
223
  }> {
221
- const lock = acquireLock(state.homeDir, "upgrade");
224
+ const lock = acquireInstallLock(state.stateDir);
225
+ if (!lock) throw new Error("Another install is already in progress");
222
226
  try {
223
227
  const { backupDir, updated } = await refreshCoreAssets();
224
228
  const restarted = await reconcileCore(state, {});
225
229
  return { backupDir, updated, restarted };
226
230
  } finally {
227
- releaseLock(lock);
231
+ releaseInstallLock(lock);
228
232
  }
229
233
  }
230
234
 
@@ -0,0 +1,130 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
6
+
7
+ let tempDir = "";
8
+
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), "openpalm-opids-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tempDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("resolveOperatorIds", () => {
18
+ test("returns the homeDir's owner when it exists and is non-root", () => {
19
+ // A mkdtemp directory is owned by the current process — neither
20
+ // root (in any reasonable test env) nor a hard-coded 1000.
21
+ const expected = statSync(tempDir);
22
+ const ids = resolveOperatorIds(tempDir);
23
+ if (process.platform === "win32") {
24
+ expect(ids).toBeNull();
25
+ return;
26
+ }
27
+ expect(ids).not.toBeNull();
28
+ expect(ids!.uid).toBe(expected.uid);
29
+ expect(ids!.gid).toBe(expected.gid);
30
+ });
31
+
32
+ test("falls back to process UID when homeDir does not exist", () => {
33
+ const missing = join(tempDir, "does-not-exist");
34
+ const ids = resolveOperatorIds(missing);
35
+ if (process.platform === "win32") {
36
+ expect(ids).toBeNull();
37
+ return;
38
+ }
39
+ expect(ids).not.toBeNull();
40
+ // process.getuid is guaranteed on POSIX runtimes used by this test
41
+ expect(ids!.uid).toBe(process.getuid!());
42
+ expect(ids!.gid).toBe(process.getgid!());
43
+ });
44
+
45
+ test("never returns 0 (root) — falls back to process UID when homeDir is root-owned", () => {
46
+ // We can't easily chown a dir to root without root. Instead, exercise
47
+ // the branch via a faked statSync output: build a path that triggers
48
+ // the "owner is 0, prefer process UID" code path by ensuring real
49
+ // tempDir owner is the process UID and asserting the result for a
50
+ // missing path matches process UID (already covered above). The
51
+ // explicit 0-check is enforced by the implementation; this test
52
+ // documents that the function never *returns* 0 for any of the
53
+ // exercised inputs in a non-root test process.
54
+ const ids = resolveOperatorIds(tempDir);
55
+ if (process.platform === "win32") {
56
+ expect(ids).toBeNull();
57
+ return;
58
+ }
59
+ expect(ids).not.toBeNull();
60
+ expect(ids!.uid).toBeGreaterThan(0);
61
+ expect(ids!.gid).toBeGreaterThan(0);
62
+ });
63
+
64
+ test("returns null when BOTH homeDir owner and process UID/GID are 0 (root install on root-owned OP_HOME)", () => {
65
+ if (process.platform === "win32") {
66
+ // win32 short-circuits before any of this logic
67
+ expect(resolveOperatorIds(tempDir)).toBeNull();
68
+ return;
69
+ }
70
+
71
+ // Stub process.getuid / getgid to simulate running as root. On Linux,
72
+ // `/` is owned by uid=0 gid=0, so passing "/" gives us a root-owned
73
+ // homeDir. Combined with the stubbed process IDs, this hits the
74
+ // "both signals are root" branch that previously returned {0,0}.
75
+ const origGetuid = process.getuid;
76
+ const origGetgid = process.getgid;
77
+ try {
78
+ (process as unknown as { getuid: () => number }).getuid = () => 0;
79
+ (process as unknown as { getgid: () => number }).getgid = () => 0;
80
+ // Sanity-check the assumption that "/" is root-owned in this env
81
+ // before relying on it as a fixture. On macOS / Linux CI runners
82
+ // this holds; if a future weird env breaks it, the assertion
83
+ // surfaces clearly rather than producing a confusing pass.
84
+ const rootStat = statSync("/");
85
+ expect(rootStat.uid).toBe(0);
86
+ expect(rootStat.gid).toBe(0);
87
+
88
+ const ids = resolveOperatorIds("/");
89
+ expect(ids).toBeNull();
90
+ } finally {
91
+ (process as unknown as { getuid: typeof origGetuid }).getuid = origGetuid;
92
+ (process as unknown as { getgid: typeof origGetgid }).getgid = origGetgid;
93
+ }
94
+ });
95
+
96
+ test("returns null on win32", () => {
97
+ // This test is informational; on non-win32 it doesn't run the win32
98
+ // branch. The check is left here for documentation and runs as a
99
+ // no-op assertion on POSIX.
100
+ if (process.platform === "win32") {
101
+ expect(resolveOperatorIds(tempDir)).toBeNull();
102
+ } else {
103
+ // No-op: confirms the test compiles and the helper is callable.
104
+ expect(typeof resolveOperatorIds).toBe("function");
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("hasUsableOperatorId", () => {
110
+ test("returns true for positive numeric values", () => {
111
+ expect(hasUsableOperatorId({ OP_UID: "1000" }, "OP_UID")).toBe(true);
112
+ expect(hasUsableOperatorId({ OP_GID: "501" }, "OP_GID")).toBe(true);
113
+ });
114
+
115
+ test("returns false for missing key", () => {
116
+ expect(hasUsableOperatorId({}, "OP_UID")).toBe(false);
117
+ });
118
+
119
+ test("returns false for empty string", () => {
120
+ expect(hasUsableOperatorId({ OP_UID: "" }, "OP_UID")).toBe(false);
121
+ });
122
+
123
+ test("returns false for zero", () => {
124
+ expect(hasUsableOperatorId({ OP_UID: "0" }, "OP_UID")).toBe(false);
125
+ });
126
+
127
+ test("returns false for non-numeric garbage", () => {
128
+ expect(hasUsableOperatorId({ OP_UID: "abc" }, "OP_UID")).toBe(false);
129
+ });
130
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Operator UID/GID detection for stack.env.
3
+ *
4
+ * Container processes that bind-mount host paths (voice models, addon
5
+ * caches, etc.) run as `${OP_UID}:${OP_GID}`. If those values are wrong,
6
+ * the container can't write to the mounted volume and the install
7
+ * silently degrades (model downloads stall, healthchecks time out).
8
+ *
9
+ * Detection strategy (Linux/macOS):
10
+ * 1. Stat OP_HOME. If it exists and is owned by a non-root user,
11
+ * prefer that owner — operator may have created OP_HOME under a
12
+ * different account than the one running install (e.g. sudo
13
+ * install for a service user).
14
+ * 2. Otherwise fall back to the process's real UID/GID.
15
+ * 3. Never return 0 (root). Running install as root is allowed but
16
+ * the container must run as the operator, not root.
17
+ *
18
+ * Returns `null` on Windows (containers run in WSL2's Linux; OP_UID
19
+ * has no meaning on the win32 host process itself).
20
+ */
21
+ import { statSync } from "node:fs";
22
+
23
+ export type OperatorIds = { uid: number; gid: number };
24
+
25
+ /**
26
+ * Resolve the operator's UID/GID for stack.env.
27
+ * Returns null on Windows or when neither homeDir owner nor process
28
+ * UID/GID is available (e.g. process.getuid undefined on some runtimes).
29
+ */
30
+ export function resolveOperatorIds(homeDir: string): OperatorIds | null {
31
+ if (process.platform === "win32") return null;
32
+
33
+ const processUid = typeof process.getuid === "function" ? process.getuid() : undefined;
34
+ const processGid = typeof process.getgid === "function" ? process.getgid() : undefined;
35
+
36
+ let ownerUid: number | undefined;
37
+ let ownerGid: number | undefined;
38
+ try {
39
+ const st = statSync(homeDir);
40
+ ownerUid = st.uid;
41
+ ownerGid = st.gid;
42
+ } catch {
43
+ // homeDir may not exist yet during a first-time install — that's fine,
44
+ // we fall through to the process IDs below.
45
+ }
46
+
47
+ // Prefer the homeDir owner when it's a non-root user (the operator may
48
+ // have created OP_HOME under a different account than the one running
49
+ // install — e.g. an admin running `sudo openpalm install` on behalf of
50
+ // a service account).
51
+ const uid =
52
+ ownerUid !== undefined && ownerUid !== 0
53
+ ? ownerUid
54
+ : processUid !== undefined && processUid !== 0
55
+ ? processUid
56
+ : ownerUid; // last resort: homeDir owner even if 0, or undefined
57
+
58
+ const gid =
59
+ ownerGid !== undefined && ownerGid !== 0
60
+ ? ownerGid
61
+ : processGid !== undefined && processGid !== 0
62
+ ? processGid
63
+ : ownerGid;
64
+
65
+ if (uid === undefined || gid === undefined) return null;
66
+
67
+ // Final guard: never return 0 (root). This happens when BOTH the OP_HOME
68
+ // owner AND the process UID are root (e.g. `sudo openpalm install` on a
69
+ // freshly-created root-owned OP_HOME, common in CI builds and Docker-based
70
+ // installer flows). Returning null causes the caller to skip writing
71
+ // OP_UID/OP_GID to stack.env, and compose's `${OP_UID:-1000}` default
72
+ // kicks in — container runs as 1000:1000, which is the sane fallback
73
+ // when no real operator can be detected.
74
+ if (uid === 0 || gid === 0) return null;
75
+
76
+ return { uid, gid };
77
+ }
78
+
79
+ /**
80
+ * Returns true if the parsed stack.env already has a usable
81
+ * (non-zero, numeric) operator ID for the given key.
82
+ * Operator may have hand-set OP_UID/OP_GID; respect that.
83
+ */
84
+ export function hasUsableOperatorId(parsed: Record<string, string>, key: "OP_UID" | "OP_GID"): boolean {
85
+ const raw = parsed[key];
86
+ if (!raw) return false;
87
+ const n = Number(raw);
88
+ return Number.isFinite(n) && n > 0;
89
+ }