@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,177 @@
1
+ /**
2
+ * Tests for the 0.11.0 auth-migration shim.
3
+ */
4
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ chmodSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { migrateAuth0110 } from "./migrate-0110.js";
18
+ import type { ControlPlaneState } from "./types.js";
19
+
20
+ function makeState(homeDir: string): ControlPlaneState {
21
+ return {
22
+ homeDir,
23
+ configDir: join(homeDir, "config"),
24
+ stashDir: join(homeDir, "stash"),
25
+ workspaceDir: join(homeDir, "workspace"),
26
+ cacheDir: join(homeDir, "cache"),
27
+ stateDir: join(homeDir, "state"),
28
+ stackDir: join(homeDir, "config", "stack"),
29
+ services: {},
30
+ artifacts: { compose: "" },
31
+ artifactMeta: [],
32
+ };
33
+ }
34
+
35
+ function seedStackEnv(stackDir: string, content: string): string {
36
+ mkdirSync(stackDir, { recursive: true });
37
+ const path = join(stackDir, "stack.env");
38
+ writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
39
+ chmodSync(path, 0o600);
40
+ return path;
41
+ }
42
+
43
+ describe("migrateAuth0110", () => {
44
+ let homeDir: string;
45
+
46
+ beforeEach(() => {
47
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-migrate-0110-"));
48
+ });
49
+
50
+ afterEach(() => {
51
+ rmSync(homeDir, { recursive: true, force: true });
52
+ });
53
+
54
+ it("no-ops on a fresh install (no stack.env)", () => {
55
+ const state = makeState(homeDir);
56
+ const result = migrateAuth0110(state);
57
+ expect(result.migrated).toBe(false);
58
+ expect(result.reason).toContain("fresh install");
59
+ });
60
+
61
+ it("promotes OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD and removes legacy keys", () => {
62
+ const state = makeState(homeDir);
63
+ const stackEnvPath = seedStackEnv(
64
+ state.stackDir,
65
+ [
66
+ "# header",
67
+ "OP_UI_TOKEN=legacy-token-value",
68
+ "OP_ASSISTANT_TOKEN=some-assistant-token",
69
+ "OP_OPENCODE_PASSWORD=opencode-secret",
70
+ "",
71
+ ].join("\n"),
72
+ );
73
+
74
+ const result = migrateAuth0110(state);
75
+ expect(result.migrated).toBe(true);
76
+ expect(result.reason).toContain("promoted OP_UI_TOKEN");
77
+ expect(result.reason).toContain("removed OP_UI_TOKEN");
78
+ expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
79
+
80
+ const after = readFileSync(stackEnvPath, "utf-8");
81
+ expect(after).toContain("OP_UI_LOGIN_PASSWORD=legacy-token-value");
82
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
83
+ expect(after).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
84
+ // Unrelated keys preserved
85
+ expect(after).toContain("OP_OPENCODE_PASSWORD=opencode-secret");
86
+
87
+ // Perms preserved
88
+ expect(statSync(stackEnvPath).mode & 0o777).toBe(0o600);
89
+
90
+ // Migration log appended
91
+ const logPath = join(state.stateDir, "logs", "migration-0.11.0.log");
92
+ expect(existsSync(logPath)).toBe(true);
93
+ const log = readFileSync(logPath, "utf-8");
94
+ expect(log).toContain("migrate-auth-0110");
95
+ expect(log).toContain("promoted OP_UI_TOKEN");
96
+ });
97
+
98
+ it("does not overwrite an existing OP_UI_LOGIN_PASSWORD", () => {
99
+ const state = makeState(homeDir);
100
+ const stackEnvPath = seedStackEnv(
101
+ state.stackDir,
102
+ [
103
+ "OP_UI_LOGIN_PASSWORD=new-password",
104
+ "OP_UI_TOKEN=legacy-value",
105
+ "",
106
+ ].join("\n"),
107
+ );
108
+
109
+ const result = migrateAuth0110(state);
110
+ expect(result.migrated).toBe(true);
111
+ expect(result.reason).not.toContain("promoted");
112
+ expect(result.reason).toContain("removed OP_UI_TOKEN");
113
+
114
+ const after = readFileSync(stackEnvPath, "utf-8");
115
+ expect(after).toContain("OP_UI_LOGIN_PASSWORD=new-password");
116
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
117
+ });
118
+
119
+ it("removes OP_ASSISTANT_TOKEN even when only it is present", () => {
120
+ const state = makeState(homeDir);
121
+ const stackEnvPath = seedStackEnv(
122
+ state.stackDir,
123
+ [
124
+ "OP_UI_LOGIN_PASSWORD=pw",
125
+ "OP_ASSISTANT_TOKEN=stale",
126
+ "",
127
+ ].join("\n"),
128
+ );
129
+
130
+ const result = migrateAuth0110(state);
131
+ expect(result.migrated).toBe(true);
132
+ expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
133
+ expect(readFileSync(stackEnvPath, "utf-8")).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
134
+ });
135
+
136
+ it("is idempotent: second run reports already-migrated", () => {
137
+ const state = makeState(homeDir);
138
+ seedStackEnv(
139
+ state.stackDir,
140
+ [
141
+ "OP_UI_TOKEN=t",
142
+ "OP_ASSISTANT_TOKEN=t2",
143
+ "",
144
+ ].join("\n"),
145
+ );
146
+
147
+ const first = migrateAuth0110(state);
148
+ expect(first.migrated).toBe(true);
149
+
150
+ const second = migrateAuth0110(state);
151
+ expect(second.migrated).toBe(false);
152
+ expect(second.reason).toContain("already migrated");
153
+ });
154
+
155
+ it("treats an empty OP_UI_TOKEN value as not-set (no promotion)", () => {
156
+ const state = makeState(homeDir);
157
+ const stackEnvPath = seedStackEnv(
158
+ state.stackDir,
159
+ [
160
+ "OP_UI_TOKEN=",
161
+ "OP_ASSISTANT_TOKEN=foo",
162
+ "",
163
+ ].join("\n"),
164
+ );
165
+
166
+ const result = migrateAuth0110(state);
167
+ expect(result.migrated).toBe(true);
168
+ // Empty-string OP_UI_TOKEN should NOT be promoted as a password.
169
+ expect(result.reason).not.toContain("promoted");
170
+
171
+ const after = readFileSync(stackEnvPath, "utf-8");
172
+ // The empty OP_UI_TOKEN line is still removed.
173
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
174
+ // No OP_UI_LOGIN_PASSWORD added (would be an empty value).
175
+ expect(after).not.toMatch(/^OP_UI_LOGIN_PASSWORD=/m);
176
+ });
177
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One-shot migration for the 0.11.0 auth refactor.
3
+ *
4
+ * Existing installs have OP_UI_TOKEN and OP_ASSISTANT_TOKEN in
5
+ * config/stack/stack.env. The 0.11.0 refactor (auth-and-proxy-refactor-plan.md)
6
+ * replaces them with a single OP_UI_LOGIN_PASSWORD. If we don't migrate,
7
+ * operators get locked out the moment they run the new UI build because the
8
+ * login route compares the cookie against process.env.OP_UI_LOGIN_PASSWORD,
9
+ * which is empty on existing installs.
10
+ *
11
+ * Migration logic (idempotent):
12
+ * - If OP_UI_LOGIN_PASSWORD is unset AND OP_UI_TOKEN is set, copy
13
+ * OP_UI_TOKEN's value into OP_UI_LOGIN_PASSWORD.
14
+ * - Remove OP_UI_TOKEN and OP_ASSISTANT_TOKEN from stack.env (they're
15
+ * no longer used).
16
+ * - Append a one-line summary to state/logs/migration-0.11.0.log.
17
+ * - If OP_UI_LOGIN_PASSWORD is already set, leave it alone — the operator
18
+ * already migrated or set up fresh.
19
+ *
20
+ * Called from ensureSecrets so it runs before any auth-required code path
21
+ * gets a chance to see the half-migrated state.
22
+ */
23
+ import {
24
+ existsSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ chmodSync,
28
+ appendFileSync,
29
+ mkdirSync,
30
+ } from "node:fs";
31
+ import { dirname } from "node:path";
32
+ import { parseEnvContent, removeEnvKey, upsertEnvValue } from "./env.js";
33
+ import { migration0110LogPath } from "./paths.js";
34
+ import type { ControlPlaneState } from "./types.js";
35
+
36
+ export type MigrateAuth0110Result = {
37
+ /** True if any change was written to stack.env. */
38
+ migrated: boolean;
39
+ /** Human-readable description of what changed (or why nothing did). */
40
+ reason: string;
41
+ };
42
+
43
+ export function migrateAuth0110(state: ControlPlaneState): MigrateAuth0110Result {
44
+ const stackEnvPath = `${state.stackDir}/stack.env`;
45
+ if (!existsSync(stackEnvPath)) {
46
+ return { migrated: false, reason: "no stack.env yet (fresh install)" };
47
+ }
48
+
49
+ const before = readFileSync(stackEnvPath, "utf-8");
50
+ const parsed = parseEnvContent(before);
51
+ const hasLoginPw = typeof parsed.OP_UI_LOGIN_PASSWORD === "string" && parsed.OP_UI_LOGIN_PASSWORD.length > 0;
52
+ const hasUiToken = typeof parsed.OP_UI_TOKEN === "string" && parsed.OP_UI_TOKEN.length > 0;
53
+ const hasAssistantToken = "OP_ASSISTANT_TOKEN" in parsed;
54
+ const hasUiTokenLine = "OP_UI_TOKEN" in parsed;
55
+
56
+ if (hasLoginPw && !hasUiTokenLine && !hasAssistantToken) {
57
+ return { migrated: false, reason: "already migrated" };
58
+ }
59
+
60
+ let content = before;
61
+ const changes: string[] = [];
62
+
63
+ if (!hasLoginPw && hasUiToken) {
64
+ content = upsertEnvValue(content, "OP_UI_LOGIN_PASSWORD", parsed.OP_UI_TOKEN);
65
+ changes.push("promoted OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD");
66
+ }
67
+ if (hasUiTokenLine) {
68
+ content = removeEnvKey(content, "OP_UI_TOKEN");
69
+ changes.push("removed OP_UI_TOKEN");
70
+ }
71
+ if (hasAssistantToken) {
72
+ content = removeEnvKey(content, "OP_ASSISTANT_TOKEN");
73
+ changes.push("removed OP_ASSISTANT_TOKEN");
74
+ }
75
+
76
+ if (changes.length === 0) {
77
+ return { migrated: false, reason: "no changes needed" };
78
+ }
79
+
80
+ // Preserve the 0600 mode the existing file should already have.
81
+ writeFileSync(stackEnvPath, content, { encoding: "utf-8", mode: 0o600 });
82
+ try { chmodSync(stackEnvPath, 0o600); } catch { /* best-effort */ }
83
+
84
+ // Best-effort audit line. The migration log is small and append-only;
85
+ // if it fails (perm error, fs full), we don't roll back the migration.
86
+ try {
87
+ const logPath = migration0110LogPath(state);
88
+ mkdirSync(dirname(logPath), { recursive: true });
89
+ appendFileSync(
90
+ logPath,
91
+ `${new Date().toISOString()} migrate-auth-0110 ${changes.join("; ")}\n`,
92
+ "utf-8",
93
+ );
94
+ } catch {
95
+ /* best-effort */
96
+ }
97
+
98
+ return { migrated: true, reason: changes.join("; ") };
99
+ }
@@ -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
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Authoritative path resolution for the OpenPalm control plane.
3
+ *
4
+ * Every consumer imports from here instead of concatenating paths inline.
5
+ * When the directory layout changes, update this file only.
6
+ *
7
+ * Layout:
8
+ * config/ — user-editable config + system config files (auth.json, akm/)
9
+ * config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
10
+ * cache/ — regenerable/semi-persistent data (akm cache, guardian cache, rollback)
11
+ * state/ — persistent service data (assistant, admin, guardian, logs, backups, registry)
12
+ * stash/ — akm knowledge (skills, vaults, agents)
13
+ * workspace/ — shared work area
14
+ */
15
+ import type { ControlPlaneState } from "./types.js";
16
+
17
+ // ── Config directory — user + system config ─────────────────────────────────
18
+
19
+ /** OpenCode auth token store */
20
+ export const authJsonPath = (s: ControlPlaneState): string => `${s.configDir}/auth.json`;
21
+ /** akm setup config directory (AKM_CONFIG_DIR) */
22
+ export const akmConfigDir = (s: ControlPlaneState): string => `${s.configDir}/akm`;
23
+ /** akm setup config file (written by admin on capability save) */
24
+ export const akmConfigPath = (s: ControlPlaneState): string => `${s.configDir}/akm/config.json`;
25
+ export const tasksDir = (s: ControlPlaneState): string => `${s.stashDir}/tasks`;
26
+ export const assistantConfigDir = (s: ControlPlaneState): string => `${s.configDir}/assistant`;
27
+
28
+ // ── Config/stack directory — compose runtime + stack config ─────────────────
29
+
30
+ /** System env: capabilities, secrets, tokens */
31
+ export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
32
+ /** Guardian HMAC channel secrets */
33
+ export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
34
+
35
+ // ── Cache directory — regenerable/semi-persistent ───────────────────────────
36
+
37
+ export const akmCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm`;
38
+ export const guardianCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/guardian`;
39
+ export const rollbackDir = (s: ControlPlaneState): string => `${s.cacheDir}/rollback`;
40
+
41
+ // ── State directory — persistent service data ───────────────────────────────
42
+
43
+ export const assistantServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/assistant`;
44
+ export const adminServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/admin`;
45
+ export const guardianServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian`;
46
+ export const guardianStashDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/stash`;
47
+ export const guardianAkmDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/akm`;
48
+ /** Shared akm operational data (data/, state/ — NOT config, which lives in config/akm/) */
49
+ export const akmStateDir = (s: ControlPlaneState): string => `${s.stateDir}/akm`;
50
+ export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
51
+ export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
52
+ export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
53
+ /**
54
+ * Guardian's own audit log of channel ingress (HMAC verify, replay, rate
55
+ * limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
56
+ * `admin-audit.jsonl` — OpenCode session logs are the audit trail for
57
+ * chat + tool activity.
58
+ */
59
+ export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
60
+ /** One-shot 0.11.0 migration log (OP_UI_TOKEN → OPENCODE_SERVER_PASSWORD, endpoints.json move) */
61
+ export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
62
+ export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
63
+ export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
64
+ export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
65
+ export const registryAutomationsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/automations`;
66
+ export const secretsDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets`;
67
+ export const secretProviderPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/provider.json`;
68
+ export const secretsIndexPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/plaintext-index.json`;
69
+ export const passStoreDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets/pass-store`;
70
+
71
+ // ── Stash directory ─────────────────────────────────────────────────────────
72
+
73
+ /** akm vault:user file — lives in the stash */
74
+ export const akmUserVaultPath = (s: ControlPlaneState): string => `${s.stashDir}/vaults/user.env`;
75
+
76
+ // ── Stack directory ─────────────────────────────────────────────────────────
77
+
78
+ export const coreComposePath = (s: ControlPlaneState): string => `${s.stackDir}/core.compose.yml`;
79
+ export const addonsStackDir = (s: ControlPlaneState): string => `${s.stackDir}/addons`;
80
+ export const addonComposePath = (s: ControlPlaneState, name: string): string => `${s.stackDir}/addons/${name}/compose.yml`;