@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.
@@ -8,8 +8,6 @@ export type CoreServiceName =
8
8
  | "assistant"
9
9
  | "guardian";
10
10
 
11
- export type OptionalServiceName = never;
12
-
13
11
  export type AccessScope = "host" | "lan";
14
12
  export type CallerType = "assistant" | "cli" | "ui" | "system" | "test" | "unknown";
15
13
 
@@ -51,5 +49,3 @@ export const CORE_SERVICES: CoreServiceName[] = [
51
49
  "guardian",
52
50
  ];
53
51
 
54
- export const OPTIONAL_SERVICES: OptionalServiceName[] = [];
55
-
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import {
14
14
  existsSync, mkdirSync, readdirSync, copyFileSync,
15
- writeFileSync, rmSync, realpathSync, renameSync,
15
+ readFileSync, writeFileSync, rmSync, realpathSync, renameSync,
16
16
  } from 'node:fs';
17
17
  import { join, dirname, relative } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
@@ -156,7 +156,13 @@ export function resolveLocalUiBuild(): string | null {
156
156
  () => process.env.OPENPALM_REPO_ROOT
157
157
  ? join(process.env.OPENPALM_REPO_ROOT, 'packages', 'ui', 'build')
158
158
  : null,
159
- // 2. Relative to this source file (dev / bun run)
159
+ // 2. Electron extraResources ui-build/ is placed alongside the asar
160
+ () => {
161
+ const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
162
+ if (!rp) return null;
163
+ return join(rp, 'ui-build');
164
+ },
165
+ // 3. Relative to this source file (dev / bun run)
160
166
  () => {
161
167
  const meta = fileURLToPath(import.meta.url);
162
168
  if (meta.startsWith('/$bunfs/')) return null;
@@ -164,7 +170,7 @@ export function resolveLocalUiBuild(): string | null {
164
170
  const candidate = join(dirname(meta), '..', '..', '..', '..', 'packages', 'ui', 'build');
165
171
  return existsSync(join(candidate, 'index.js')) ? candidate : null;
166
172
  },
167
- // 3. Relative to compiled binary / Electron executable
173
+ // 4. Relative to compiled binary / Electron executable
168
174
  () => {
169
175
  const binDir = dirname(realpathSync(process.execPath));
170
176
  const candidate = join(binDir, '..', '..', '..', 'packages', 'ui', 'build');
@@ -173,17 +179,36 @@ export function resolveLocalUiBuild(): string | null {
173
179
  );
174
180
  }
175
181
 
182
+ function readUiVersionFile(dir: string): string | null {
183
+ try { return readFileSync(join(dir, 'version.txt'), 'utf-8').trim(); } catch { return null; }
184
+ }
185
+
176
186
  /**
177
187
  * Resolve the best available UI build directory at runtime.
178
188
  *
179
189
  * Priority:
180
- * 1. OP_HOME/state/ui/ — installed by seedUiBuild (production)
181
- * 2. Local packages/ui/build/ dev / source install fallback
190
+ * 1. OP_HOME/state/ui/ — if its version.txt is NEWER than the bundled build
191
+ * 2. Bundled / local build (Electron extraResources, source checkout)
192
+ * 3. OP_HOME/state/ui/ — fallback when no bundled build exists
193
+ *
194
+ * This means GitHub-downloaded updates are applied automatically (disk wins
195
+ * when newer), but a fresh AppImage install always works without a download.
182
196
  */
183
197
  export function resolveUiBuildDir(): string {
184
198
  const stateBuild = join(resolveStateDir(), 'ui');
185
- if (existsSync(join(stateBuild, 'index.js'))) return stateBuild;
186
- return resolveLocalUiBuild() ?? stateBuild; // fall back even if missing (error surfaces later)
199
+ const localBuild = resolveLocalUiBuild();
200
+
201
+ if (existsSync(join(stateBuild, 'index.js')) && localBuild) {
202
+ const diskVer = readUiVersionFile(stateBuild);
203
+ const bundledVer = readUiVersionFile(localBuild);
204
+ if (diskVer && bundledVer && compareVersionTags(diskVer, bundledVer) > 0) {
205
+ return stateBuild;
206
+ }
207
+ return localBuild;
208
+ }
209
+
210
+ if (localBuild) return localBuild;
211
+ return stateBuild;
187
212
  }
188
213
 
189
214
  /**
@@ -224,6 +249,7 @@ export async function seedUiBuild(repoRef: string, stateDir: string): Promise<vo
224
249
  if (local) {
225
250
  logger.debug('seeding UI build from local source', { src: local });
226
251
  copyTree(local, uiDir);
252
+ writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
227
253
  return;
228
254
  }
229
255
 
@@ -260,6 +286,7 @@ export async function seedUiBuild(repoRef: string, stateDir: string): Promise<vo
260
286
 
261
287
  // Cross-platform extraction via the `tar` npm package — no shell dependency
262
288
  await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
289
+ writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
263
290
  } finally {
264
291
  rmSync(tmpTar, { force: true });
265
292
  }
package/src/index.ts CHANGED
@@ -25,14 +25,12 @@ export {
25
25
  export type {
26
26
  ControlPlaneState,
27
27
  CoreServiceName,
28
- OptionalServiceName,
29
28
  ChannelInfo,
30
29
  CallerType,
31
30
  ArtifactMeta,
32
31
  } from "./control-plane/types.js";
33
32
  export {
34
33
  CORE_SERVICES,
35
- OPTIONAL_SERVICES,
36
34
  } from "./control-plane/types.js";
37
35
 
38
36
  // ── Backups ───────────────────────────────────────────────────────────────
@@ -44,6 +42,7 @@ export {
44
42
  export type {
45
43
  AddonMutationResult,
46
44
  AddonProfile,
45
+ AddonProfileAvailability,
47
46
  RegistryAutomationEntry,
48
47
  RegistryComponentEntry,
49
48
  RegistryAddonConfig,
@@ -59,12 +58,12 @@ export {
59
58
  getRegistryAddonConfig,
60
59
  getAddonServiceNames,
61
60
  getAddonProfiles,
61
+ getAddonProfileAvailability,
62
+ annotateAddonProfileAvailability,
62
63
  getAddonProfileSelection,
63
64
  setAddonProfileSelection,
64
65
  listAvailableAddonIds,
65
66
  listEnabledAddonIds,
66
- enableAddon,
67
- disableAddonByName,
68
67
  setAddonEnabled,
69
68
  installAutomationFromRegistry,
70
69
  uninstallAutomation,
@@ -116,11 +115,6 @@ export {
116
115
  } from "./control-plane/secrets.js";
117
116
  export { migrateAuth0110 } from "./control-plane/migrate-0110.js";
118
117
  export type { MigrateAuth0110Result } from "./control-plane/migrate-0110.js";
119
- export {
120
- detectSecretBackend,
121
- validatePassEntryName,
122
- } from "./control-plane/secret-backend.js";
123
- export type { SecretBackend } from "./control-plane/secret-backend.js";
124
118
  // ── Setup Status ────────────────────────────────────────────────────────
125
119
  export {
126
120
  isSetupComplete,
@@ -149,6 +143,7 @@ export {
149
143
  ensureOpenCodeSystemConfig,
150
144
  refreshCoreAssets,
151
145
  seedStashAssets,
146
+ seedAssistantPersonaFiles,
152
147
  } from "./control-plane/core-assets.js";
153
148
 
154
149
  // ── Configuration Persistence ────────────────────────────────────────────
@@ -259,6 +254,13 @@ export {
259
254
  writeVoiceVars,
260
255
  } from "./control-plane/spec-to-env.js";
261
256
 
257
+ // ── Operator UID/GID Detection ──────────────────────────────────────────
258
+ export type { OperatorIds } from "./control-plane/operator-ids.js";
259
+ export {
260
+ resolveOperatorIds,
261
+ hasUsableOperatorId,
262
+ } from "./control-plane/operator-ids.js";
263
+
262
264
  // ── Setup ────────────────────────────────────────────────────────────────
263
265
  export type {
264
266
  SetupSpec,
@@ -34,7 +34,7 @@ describe('redactValue', () => {
34
34
  });
35
35
 
36
36
  test('leaves non-secret values alone', () => {
37
- expect(redactValue('OWNER_NAME', 'alice')).toBe('alice');
37
+ expect(redactValue('OP_OWNER_NAME', 'alice')).toBe('alice');
38
38
  expect(redactValue('OP_HOME', '/openpalm')).toBe('/openpalm');
39
39
  expect(redactValue('OP_ASSISTANT_PORT', '3800')).toBe('3800');
40
40
  });
@@ -71,7 +71,7 @@ describe('isSensitiveEnvKey', () => {
71
71
  });
72
72
 
73
73
  test('returns false for ordinary keys', () => {
74
- expect(isSensitiveEnvKey('OWNER_NAME')).toBe(false);
74
+ expect(isSensitiveEnvKey('OP_OWNER_NAME')).toBe(false);
75
75
  expect(isSensitiveEnvKey('OP_HOME')).toBe(false);
76
76
  expect(isSensitiveEnvKey('OP_ASSISTANT_PORT')).toBe(false);
77
77
  });
@@ -81,11 +81,11 @@ describe('redactExtra', () => {
81
81
  test('masks top-level secret string values', () => {
82
82
  const result = redactExtra({
83
83
  OPENAI_API_KEY: 'sk-abc',
84
- OWNER_NAME: 'alice',
84
+ OP_OWNER_NAME: 'alice',
85
85
  });
86
86
  expect(result).toEqual({
87
87
  OPENAI_API_KEY: '***REDACTED***',
88
- OWNER_NAME: 'alice',
88
+ OP_OWNER_NAME: 'alice',
89
89
  });
90
90
  });
91
91
 
@@ -93,13 +93,13 @@ describe('redactExtra', () => {
93
93
  const result = redactExtra({
94
94
  env: {
95
95
  OPENAI_API_KEY: 'sk-abc',
96
- OWNER_NAME: 'alice',
96
+ OP_OWNER_NAME: 'alice',
97
97
  },
98
98
  });
99
99
  expect(result).toEqual({
100
100
  env: {
101
101
  OPENAI_API_KEY: '***REDACTED***',
102
- OWNER_NAME: 'alice',
102
+ OP_OWNER_NAME: 'alice',
103
103
  },
104
104
  });
105
105
  });
@@ -108,13 +108,13 @@ describe('redactExtra', () => {
108
108
  const result = redactExtra({
109
109
  items: [
110
110
  { OPENAI_API_KEY: 'sk-1' },
111
- { OWNER_NAME: 'bob' },
111
+ { OP_OWNER_NAME: 'bob' },
112
112
  ],
113
113
  });
114
114
  expect(result).toEqual({
115
115
  items: [
116
116
  { OPENAI_API_KEY: '***REDACTED***' },
117
- { OWNER_NAME: 'bob' },
117
+ { OP_OWNER_NAME: 'bob' },
118
118
  ],
119
119
  });
120
120
  });
@@ -129,12 +129,12 @@ describe('redactExtra', () => {
129
129
  const result = redactExtra({
130
130
  OP_UI_TOKEN: 12345,
131
131
  OPENAI_API_KEY: true,
132
- OWNER_NAME: 'alice',
132
+ OP_OWNER_NAME: 'alice',
133
133
  });
134
134
  expect(result).toEqual({
135
135
  OP_UI_TOKEN: '***REDACTED***',
136
136
  OPENAI_API_KEY: '***REDACTED***',
137
- OWNER_NAME: 'alice',
137
+ OP_OWNER_NAME: 'alice',
138
138
  });
139
139
  });
140
140
 
@@ -181,10 +181,10 @@ describe('createLogger', () => {
181
181
 
182
182
  test('redacts sensitive keys in the extra payload before writing the log line', () => {
183
183
  const logger = createLogger('test');
184
- logger.info('msg', { OPENAI_API_KEY: 'sk-leak', OWNER_NAME: 'alice' });
184
+ logger.info('msg', { OPENAI_API_KEY: 'sk-leak', OP_OWNER_NAME: 'alice' });
185
185
  expect(logged.length).toBe(1);
186
186
  expect(logged[0]).toContain('"OPENAI_API_KEY":"***REDACTED***"');
187
- expect(logged[0]).toContain('"OWNER_NAME":"alice"');
187
+ expect(logged[0]).toContain('"OP_OWNER_NAME":"alice"');
188
188
  expect(logged[0]).not.toContain('sk-leak');
189
189
  });
190
190
 
@@ -1,73 +0,0 @@
1
- /**
2
- * Admin token file management.
3
- *
4
- * Token lives at {homeDir}/state/admin/token, mode 0600.
5
- * - ensureAdminToken: idempotent — skips write if file already exists and is non-empty.
6
- * - rotateAdminToken: overwrites unconditionally. Only called by `openpalm admin rotate-token`.
7
- *
8
- * Windows note: chmodSync(path, 0o600) is a no-op on Windows.
9
- * NFS/CIFS warning: mode bits are ignored on network shares. ensureAdminToken warns via console.
10
- */
11
- import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
12
- import { join } from "node:path";
13
- import { randomBytes } from "node:crypto";
14
-
15
- function getAdminStateDir(homeDir: string): string {
16
- return join(homeDir, "state", "admin");
17
- }
18
-
19
- function generateToken(): string {
20
- return randomBytes(32).toString("hex");
21
- }
22
-
23
- /**
24
- * Ensure an admin token file exists at {homeDir}/state/admin/token.
25
- * Idempotent: if the file already exists and is non-empty, returns the existing token.
26
- * Creates the directory if necessary. Sets mode 0600 (no-op on Windows).
27
- *
28
- * @param homeDir The OP_HOME directory (e.g. ~/.openpalm)
29
- * @returns The admin token (new or existing)
30
- */
31
- export function ensureAdminToken(homeDir: string): string {
32
- const dir = getAdminStateDir(homeDir);
33
- mkdirSync(dir, { recursive: true });
34
-
35
- const tokenPath = join(dir, "token");
36
-
37
- if (existsSync(tokenPath)) {
38
- const existing = readFileSync(tokenPath, "utf8").trim();
39
- if (existing.length > 0) return existing;
40
- }
41
-
42
- const token = generateToken();
43
- writeFileSync(tokenPath, token, { encoding: "utf8", mode: 0o600 });
44
- try {
45
- // Some platforms require a separate chmod call to enforce the mode.
46
- chmodSync(tokenPath, 0o600);
47
- } catch {
48
- // Windows — ignore silently
49
- }
50
- return token;
51
- }
52
-
53
- /**
54
- * Rotate the admin token. Overwrites the token file unconditionally.
55
- * Only call this from `openpalm admin rotate-token`.
56
- *
57
- * @param homeDir The OP_HOME directory
58
- * @returns The new admin token
59
- */
60
- export function rotateAdminToken(homeDir: string): string {
61
- const dir = getAdminStateDir(homeDir);
62
- mkdirSync(dir, { recursive: true });
63
-
64
- const tokenPath = join(dir, "token");
65
- const token = generateToken();
66
- writeFileSync(tokenPath, token, { encoding: "utf8", mode: 0o600 });
67
- try {
68
- chmodSync(tokenPath, 0o600);
69
- } catch {
70
- // Windows — ignore silently
71
- }
72
- return token;
73
- }
@@ -1,194 +0,0 @@
1
- /**
2
- * Tests for orchestrator lock — acquisition, contention, stale cleanup,
3
- * corrupt file handling, release, and idempotent release.
4
- */
5
- import { describe, it, expect, beforeEach, afterEach } from "bun:test";
6
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { tmpdir } from "node:os";
9
- import {
10
- acquireLock,
11
- releaseLock,
12
- lockPath,
13
- LockAcquisitionError,
14
- } from "./lock.js";
15
- import type { LockHandle, LockInfo } from "./lock.js";
16
-
17
- let opHome: string;
18
-
19
- beforeEach(() => {
20
- opHome = mkdtempSync(join(tmpdir(), "lock-test-"));
21
- mkdirSync(join(opHome, "data"), { recursive: true });
22
- });
23
-
24
- afterEach(() => {
25
- rmSync(opHome, { recursive: true, force: true });
26
- });
27
-
28
- // ── Acquisition ──────────────────────────────────────────────────────────
29
-
30
- describe("acquireLock", () => {
31
- it("creates a lock file with correct JSON content", () => {
32
- const handle = acquireLock(opHome, "install");
33
- expect(existsSync(handle.path)).toBe(true);
34
-
35
- const content = JSON.parse(readFileSync(handle.path, "utf-8"));
36
- expect(content.pid).toBe(process.pid);
37
- expect(content.operation).toBe("install");
38
- expect(typeof content.acquiredAt).toBe("string");
39
-
40
- releaseLock(handle);
41
- });
42
-
43
- it("returns a handle with correct info", () => {
44
- const handle = acquireLock(opHome, "update");
45
- expect(handle.info.pid).toBe(process.pid);
46
- expect(handle.info.operation).toBe("update");
47
- expect(handle.path).toBe(lockPath(opHome));
48
-
49
- releaseLock(handle);
50
- });
51
-
52
- it("places lock at {opHome}/data/.openpalm.lock", () => {
53
- const handle = acquireLock(opHome, "test");
54
- expect(handle.path).toBe(join(opHome, "data", ".openpalm.lock"));
55
- releaseLock(handle);
56
- });
57
- });
58
-
59
- // ── Contention ───────────────────────────────────────────────────────────
60
-
61
- describe("contention", () => {
62
- it("throws LockAcquisitionError when lock is already held by this process", () => {
63
- const handle = acquireLock(opHome, "install");
64
-
65
- try {
66
- expect(() => acquireLock(opHome, "update")).toThrow(LockAcquisitionError);
67
- } finally {
68
- releaseLock(handle);
69
- }
70
- });
71
-
72
- it("error includes holder details", () => {
73
- const handle = acquireLock(opHome, "install");
74
-
75
- try {
76
- acquireLock(opHome, "update");
77
- expect.unreachable("should have thrown");
78
- } catch (err) {
79
- expect(err).toBeInstanceOf(LockAcquisitionError);
80
- const lockErr = err as LockAcquisitionError;
81
- expect(lockErr.holder.pid).toBe(process.pid);
82
- expect(lockErr.holder.operation).toBe("install");
83
- } finally {
84
- releaseLock(handle);
85
- }
86
- });
87
- });
88
-
89
- // ── Stale PID cleanup ────────────────────────────────────────────────────
90
-
91
- describe("stale PID cleanup", () => {
92
- it("cleans up stale lock from a dead PID and acquires", () => {
93
- // Write a lock file with a PID that does not exist
94
- const stalePid = 99999999; // Very unlikely to be a real process
95
- const staleInfo: LockInfo = {
96
- pid: stalePid,
97
- operation: "old-install",
98
- acquiredAt: "2020-01-01T00:00:00.000Z",
99
- };
100
- writeFileSync(lockPath(opHome), JSON.stringify(staleInfo) + "\n");
101
-
102
- // Should succeed because the PID is dead
103
- const handle = acquireLock(opHome, "new-install");
104
- expect(handle.info.pid).toBe(process.pid);
105
- expect(handle.info.operation).toBe("new-install");
106
-
107
- releaseLock(handle);
108
- });
109
- });
110
-
111
- // ── Corrupt file handling ────────────────────────────────────────────────
112
-
113
- describe("corrupt lock file", () => {
114
- it("recovers from a corrupt lock file", () => {
115
- writeFileSync(lockPath(opHome), "not valid json{{{");
116
-
117
- const handle = acquireLock(opHome, "install");
118
- expect(handle.info.pid).toBe(process.pid);
119
-
120
- releaseLock(handle);
121
- });
122
-
123
- it("recovers from an empty lock file", () => {
124
- writeFileSync(lockPath(opHome), "");
125
-
126
- const handle = acquireLock(opHome, "install");
127
- expect(handle.info.pid).toBe(process.pid);
128
-
129
- releaseLock(handle);
130
- });
131
-
132
- it("recovers from a lock file with missing fields", () => {
133
- writeFileSync(lockPath(opHome), JSON.stringify({ pid: 1 }));
134
-
135
- const handle = acquireLock(opHome, "install");
136
- expect(handle.info.pid).toBe(process.pid);
137
-
138
- releaseLock(handle);
139
- });
140
- });
141
-
142
- // ── Release ──────────────────────────────────────────────────────────────
143
-
144
- describe("releaseLock", () => {
145
- it("removes the lock file", () => {
146
- const handle = acquireLock(opHome, "install");
147
- expect(existsSync(handle.path)).toBe(true);
148
-
149
- releaseLock(handle);
150
- expect(existsSync(handle.path)).toBe(false);
151
- });
152
-
153
- it("is idempotent — second release is a no-op", () => {
154
- const handle = acquireLock(opHome, "install");
155
- releaseLock(handle);
156
- expect(existsSync(handle.path)).toBe(false);
157
-
158
- // Second release should not throw
159
- releaseLock(handle);
160
- expect(existsSync(handle.path)).toBe(false);
161
- });
162
-
163
- it("does not remove lock owned by a different PID", () => {
164
- // Simulate a lock file owned by someone else
165
- const otherInfo: LockInfo = {
166
- pid: 99999999,
167
- operation: "other",
168
- acquiredAt: new Date().toISOString(),
169
- };
170
- writeFileSync(lockPath(opHome), JSON.stringify(otherInfo) + "\n");
171
-
172
- // Create a handle that claims to own the lock
173
- const fakeHandle: LockHandle = {
174
- path: lockPath(opHome),
175
- info: {
176
- pid: process.pid, // Different from file content
177
- operation: "mine",
178
- acquiredAt: new Date().toISOString(),
179
- },
180
- };
181
-
182
- releaseLock(fakeHandle);
183
- // Lock file should still exist because PID doesn't match
184
- expect(existsSync(lockPath(opHome))).toBe(true);
185
- });
186
- });
187
-
188
- // ── lockPath ─────────────────────────────────────────────────────────────
189
-
190
- describe("lockPath", () => {
191
- it("returns the correct path", () => {
192
- expect(lockPath("/home/user/.openpalm")).toBe("/home/user/.openpalm/data/.openpalm.lock");
193
- });
194
- });