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

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.
@@ -1,176 +0,0 @@
1
- /**
2
- * Orchestrator lock — prevents concurrent mutating operations.
3
- *
4
- * Uses O_CREAT | O_EXCL for atomic exclusive file creation.
5
- * Lock file lives at {dataDir}/.openpalm.lock containing JSON
6
- * with { pid, operation, acquiredAt }.
7
- *
8
- * Uses node:fs (not Bun) since lib must be Node-compatible for SvelteKit admin.
9
- */
10
- import { openSync, writeSync, closeSync, readFileSync, unlinkSync, mkdirSync, constants } from "node:fs";
11
- import { dirname } from "node:path";
12
-
13
- // ── Types ────────────────────────────────────────────────────────────────
14
-
15
- export type LockInfo = {
16
- pid: number;
17
- operation: string;
18
- acquiredAt: string;
19
- };
20
-
21
- export type LockHandle = {
22
- path: string;
23
- info: LockInfo;
24
- };
25
-
26
- // ── Error ────────────────────────────────────────────────────────────────
27
-
28
- export class LockAcquisitionError extends Error {
29
- public readonly holder: LockInfo;
30
-
31
- constructor(holder: LockInfo) {
32
- super(
33
- `Cannot acquire lock: already held by PID ${holder.pid} ` +
34
- `for "${holder.operation}" since ${holder.acquiredAt}`
35
- );
36
- this.name = "LockAcquisitionError";
37
- this.holder = holder;
38
- }
39
- }
40
-
41
- // ── Path ─────────────────────────────────────────────────────────────────
42
-
43
- export function lockPath(opHome: string): string {
44
- return `${opHome}/data/.openpalm.lock`;
45
- }
46
-
47
- // ── Stale PID Detection ──────────────────────────────────────────────────
48
-
49
- function isProcessAlive(pid: number): boolean {
50
- try {
51
- process.kill(pid, 0);
52
- return true;
53
- } catch {
54
- return false;
55
- }
56
- }
57
-
58
- // ── Read existing lock info ──────────────────────────────────────────────
59
-
60
- function readLockInfo(path: string): LockInfo | null {
61
- try {
62
- const content = readFileSync(path, "utf-8");
63
- const parsed = JSON.parse(content);
64
- if (
65
- typeof parsed.pid === "number" &&
66
- typeof parsed.operation === "string" &&
67
- typeof parsed.acquiredAt === "string"
68
- ) {
69
- return parsed as LockInfo;
70
- }
71
- return null;
72
- } catch {
73
- return null;
74
- }
75
- }
76
-
77
- // ── Acquire / Release ────────────────────────────────────────────────────
78
-
79
- export function acquireLock(opHome: string, operation: string): LockHandle {
80
- const path = lockPath(opHome);
81
- mkdirSync(dirname(path), { recursive: true });
82
- const info: LockInfo = {
83
- pid: process.pid,
84
- operation,
85
- acquiredAt: new Date().toISOString(),
86
- };
87
- const content = JSON.stringify(info) + "\n";
88
-
89
- try {
90
- // Atomic exclusive create — fails if file already exists
91
- const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
92
- try {
93
- writeSync(fd, content);
94
- } finally {
95
- closeSync(fd);
96
- }
97
- return { path, info };
98
- } catch (err: unknown) {
99
- // File already exists — check if it's stale
100
- if ((err as NodeJS.ErrnoException).code === "EEXIST") {
101
- const existing = readLockInfo(path);
102
-
103
- if (existing && !isProcessAlive(existing.pid)) {
104
- // Stale lock — remove and retry once
105
- try {
106
- unlinkSync(path);
107
- } catch {
108
- // Race: another process already removed it; fall through to retry
109
- }
110
- // Retry acquisition
111
- try {
112
- const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
113
- try {
114
- writeSync(fd, content);
115
- } finally {
116
- closeSync(fd);
117
- }
118
- return { path, info };
119
- } catch (retryErr: unknown) {
120
- if ((retryErr as NodeJS.ErrnoException).code === "EEXIST") {
121
- // Another process won the race — read the new holder
122
- const newHolder = readLockInfo(path);
123
- throw new LockAcquisitionError(
124
- newHolder ?? { pid: 0, operation: "unknown", acquiredAt: "unknown" }
125
- );
126
- }
127
- throw retryErr;
128
- }
129
- }
130
-
131
- // Lock is held by a live process (or corrupt file — treat as held)
132
- if (existing) {
133
- throw new LockAcquisitionError(existing);
134
- }
135
-
136
- // Corrupt lock file — remove and retry
137
- try {
138
- unlinkSync(path);
139
- } catch {
140
- // ignore
141
- }
142
- try {
143
- const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
144
- try {
145
- writeSync(fd, content);
146
- } finally {
147
- closeSync(fd);
148
- }
149
- return { path, info };
150
- } catch (retryErr: unknown) {
151
- if ((retryErr as NodeJS.ErrnoException).code === "EEXIST") {
152
- const newHolder = readLockInfo(path);
153
- throw new LockAcquisitionError(
154
- newHolder ?? { pid: 0, operation: "unknown", acquiredAt: "unknown" }
155
- );
156
- }
157
- throw retryErr;
158
- }
159
- }
160
-
161
- throw err;
162
- }
163
- }
164
-
165
- export function releaseLock(handle: LockHandle): void {
166
- // Verify ownership before deleting — only remove if we still own it
167
- const existing = readLockInfo(handle.path);
168
- if (!existing) return; // Already gone — idempotent
169
- if (existing.pid !== handle.info.pid) return; // Not ours — don't touch
170
-
171
- try {
172
- unlinkSync(handle.path);
173
- } catch {
174
- // Already removed — idempotent
175
- }
176
- }
@@ -1,34 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import type { ControlPlaneState } from './types.js';
3
-
4
- export type SecretProviderConfig = {
5
- provider: 'plaintext' | 'pass';
6
- passwordStoreDir?: string;
7
- passPrefix?: string;
8
- };
9
-
10
- function providerConfigPath(state: ControlPlaneState): string {
11
- return `${state.stateDir}/secrets/provider.json`;
12
- }
13
-
14
- export function readSecretProviderConfig(state: ControlPlaneState): SecretProviderConfig | null {
15
- const path = providerConfigPath(state);
16
- if (!existsSync(path)) return null;
17
-
18
- try {
19
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as SecretProviderConfig;
20
- if (parsed?.provider === 'plaintext' || parsed?.provider === 'pass') {
21
- return parsed;
22
- }
23
- } catch {
24
- // ignore malformed provider config and fall back to schema detection
25
- }
26
-
27
- return null;
28
- }
29
-
30
- export function writeSecretProviderConfig(state: ControlPlaneState, config: SecretProviderConfig): void {
31
- const dir = `${state.stateDir}/secrets`;
32
- mkdirSync(dir, { recursive: true });
33
- writeFileSync(providerConfigPath(state), JSON.stringify(config, null, 2) + '\n');
34
- }
@@ -1,346 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { appendFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import {
6
- detectSecretBackend,
7
- type ControlPlaneState,
8
- ensureSecrets,
9
- validatePassEntryName,
10
- } from '../index.js';
11
- import { writeSecretProviderConfig } from './provider-config.js';
12
- import { akmUserVaultPathSync } from './akm-vault.js';
13
- import { dirname } from 'node:path';
14
-
15
- let rootDir = '';
16
-
17
- function createState(): ControlPlaneState {
18
- const stateDir = join(rootDir, 'state');
19
- const configDir = join(rootDir, 'config');
20
- const stackDir = join(configDir, 'stack');
21
- const cacheDir = join(rootDir, 'cache');
22
- mkdirSync(stateDir, { recursive: true });
23
- mkdirSync(stackDir, { recursive: true });
24
- mkdirSync(configDir, { recursive: true });
25
- mkdirSync(join(rootDir, 'stash'), { recursive: true });
26
- mkdirSync(join(rootDir, 'workspace'), { recursive: true });
27
- mkdirSync(cacheDir, { recursive: true });
28
-
29
- return {
30
- homeDir: rootDir,
31
- configDir,
32
- stashDir: join(rootDir, 'stash'),
33
- workspaceDir: join(rootDir, 'workspace'),
34
- cacheDir,
35
- stateDir,
36
- stackDir,
37
- services: {},
38
- artifacts: { compose: '' },
39
- artifactMeta: [],
40
- };
41
- }
42
-
43
- beforeEach(() => {
44
- rootDir = mkdtempSync(join(tmpdir(), 'openpalm-secret-backend-'));
45
- });
46
-
47
- afterEach(() => {
48
- rmSync(rootDir, { recursive: true, force: true });
49
- });
50
-
51
- describe('secret backend', () => {
52
- test('ensureSecrets repairs auth.json when Docker created it as a directory', () => {
53
- const state = createState();
54
- mkdirSync(join(state.configDir, "auth.json"), { recursive: true });
55
-
56
- ensureSecrets(state);
57
-
58
- const authJsonPath = join(state.configDir, "auth.json");
59
- expect(lstatSync(authJsonPath).isFile()).toBe(true);
60
- expect(readFileSync(authJsonPath, 'utf-8')).toBe('{}\n');
61
- });
62
-
63
- test('detectSecretBackend defaults to plaintext and routes custom secrets into vault env files', async () => {
64
- const state = createState();
65
- ensureSecrets(state);
66
- const backend = detectSecretBackend(state);
67
-
68
- expect(backend.provider).toBe('plaintext');
69
- expect(backend.capabilities.generate).toBe(true);
70
- expect(backend.capabilities.remove).toBe(true);
71
- expect(backend.capabilities.rename).toBe(false);
72
-
73
- const entry = await backend.write('openpalm/custom/example', 'very-secret');
74
- expect(entry.provider).toBe('plaintext');
75
- expect(entry.scope).toBe('user');
76
- expect(await backend.exists('openpalm/custom/example')).toBe(true);
77
-
78
- // Custom secrets are now written to stack.env (all secrets consolidated there)
79
- const stackEnv = readFileSync(join(state.stackDir, "stack.env"), 'utf-8');
80
- expect(stackEnv).toContain('very-secret');
81
- });
82
-
83
- test('validatePassEntryName rejects traversal and invalid characters', () => {
84
- expect(() => validatePassEntryName('../bad')).toThrow();
85
- expect(() => validatePassEntryName('openpalm/Bad Key')).toThrow();
86
- expect(validatePassEntryName('openpalm/custom/good-key')).toBe('openpalm/custom/good-key');
87
- });
88
-
89
- test('validatePassEntryName rejects empty after trim', () => {
90
- expect(() => validatePassEntryName('')).toThrow('must not be empty');
91
- expect(() => validatePassEntryName(' ')).toThrow('must not be empty');
92
- expect(() => validatePassEntryName('///')).toThrow('must not be empty');
93
- });
94
-
95
- test('validatePassEntryName rejects uppercase characters', () => {
96
- expect(() => validatePassEntryName('openpalm/MyKey')).toThrow('invalid characters');
97
- expect(() => validatePassEntryName('OPENPALM/key')).toThrow('invalid characters');
98
- });
99
-
100
- test('validatePassEntryName handles multiple slashes and dots', () => {
101
- expect(validatePassEntryName('openpalm/a/b/c')).toBe('openpalm/a/b/c');
102
- expect(validatePassEntryName('openpalm/my.key')).toBe('openpalm/my.key');
103
- expect(validatePassEntryName('openpalm/my_key')).toBe('openpalm/my_key');
104
- });
105
-
106
- test('validatePassEntryName strips leading/trailing slashes', () => {
107
- expect(validatePassEntryName('/openpalm/key/')).toBe('openpalm/key');
108
- });
109
- });
110
-
111
- describe('plaintext backend (via detectSecretBackend)', () => {
112
- test('remove clears value for non-core secrets', async () => {
113
- const state = createState();
114
- ensureSecrets(state);
115
- const backend = detectSecretBackend(state);
116
-
117
- await backend.write('openpalm/custom/temp', 'temp-value');
118
- expect(await backend.exists('openpalm/custom/temp')).toBe(true);
119
-
120
- await backend.remove('openpalm/custom/temp');
121
- expect(await backend.exists('openpalm/custom/temp')).toBe(false);
122
-
123
- // Value is cleared — entry shows present: false
124
- const entries = await backend.list('openpalm/custom/');
125
- const found = entries.find((e) => e.key === 'openpalm/custom/temp');
126
- if (found) {
127
- expect(found.present).toBe(false);
128
- }
129
- });
130
-
131
- test('remove clears value but keeps index for core secrets', async () => {
132
- const state = createState();
133
- ensureSecrets(state);
134
- const backend = detectSecretBackend(state);
135
-
136
- // Write a core secret
137
- await backend.write('openpalm/admin-token', 'my-token');
138
- expect(await backend.exists('openpalm/admin-token')).toBe(true);
139
-
140
- await backend.remove('openpalm/admin-token');
141
- expect(await backend.exists('openpalm/admin-token')).toBe(false);
142
-
143
- // Core secrets still appear in list (as present: false)
144
- const entries = await backend.list('openpalm/');
145
- const found = entries.find((e) => e.key === 'openpalm/admin-token');
146
- expect(found).toBeDefined();
147
- });
148
-
149
- test('list includes both core and indexed entries', async () => {
150
- const state = createState();
151
- ensureSecrets(state);
152
- const backend = detectSecretBackend(state);
153
-
154
- await backend.write('openpalm/custom/my-key', 'value');
155
-
156
- const entries = await backend.list();
157
- const coreKeys = entries.filter((e) => e.kind === 'core');
158
- const customKeys = entries.filter((e) => e.kind === 'custom');
159
-
160
- expect(coreKeys.length).toBeGreaterThan(0);
161
- expect(customKeys.length).toBeGreaterThan(0);
162
- expect(customKeys.find((e) => e.key === 'openpalm/custom/my-key')).toBeDefined();
163
- });
164
-
165
- test('generate creates a secret with random value', async () => {
166
- const state = createState();
167
- ensureSecrets(state);
168
- const backend = detectSecretBackend(state);
169
-
170
- const entry = await backend.generate('openpalm/custom/generated', 64);
171
- expect(entry.present).toBe(true);
172
- expect(await backend.exists('openpalm/custom/generated')).toBe(true);
173
- });
174
-
175
- test('user-scope reads from akm vault, system-scope reads from stack.env', async () => {
176
- // Regression test: user scope must consult the akm vault file, system scope
177
- // must consult state/stack.env. When both files define the same key with
178
- // different values, the two scopes must return their own file's value.
179
- const state = createState();
180
- ensureSecrets(state);
181
- const backend = detectSecretBackend(state);
182
-
183
- // Seed the akm vault file with a user-scope value.
184
- const akmPath = akmUserVaultPathSync(state);
185
- mkdirSync(dirname(akmPath), { recursive: true });
186
- writeFileSync(akmPath, 'OPENAI_API_KEY=akm-vault-openai\n');
187
-
188
- // Stack.env already exists from ensureSecrets — seed the system password.
189
- const stackEnvPath = join(state.stackDir, "stack.env");
190
- const stackContent = readFileSync(stackEnvPath, 'utf-8')
191
- .replace(/^OP_UI_LOGIN_PASSWORD=.*$/m, 'OP_UI_LOGIN_PASSWORD=stack-login-password');
192
- writeFileSync(stackEnvPath, stackContent);
193
-
194
- // System scope reads stack.env exclusively.
195
- expect(await backend.exists('openpalm/ui-login-password')).toBe(true);
196
- const systemEntries = await backend.list('openpalm/ui-login-password');
197
- expect(systemEntries.find((e) => e.key === 'openpalm/ui-login-password')?.present).toBe(true);
198
-
199
- // User scope reads akm vault file.
200
- const userEntries = await backend.list('openpalm/openai/');
201
- const openai = userEntries.find((e) => e.key === 'openpalm/openai/api-key');
202
- expect(openai).toBeDefined();
203
- expect(openai?.scope).toBe('user');
204
- expect(openai?.present).toBe(true);
205
- });
206
-
207
- test('list/exists resolve user-scope secrets from akm vault', async () => {
208
- // The backend MUST resolve user-managed secrets through the akm vault file
209
- // (stash/vaults/user.env), not from any legacy path.
210
- const state = createState();
211
- ensureSecrets(state);
212
- const backend = detectSecretBackend(state);
213
-
214
- // Place the secret in the akm vault file.
215
- const akmPath = akmUserVaultPathSync(state);
216
- mkdirSync(dirname(akmPath), { recursive: true });
217
- writeFileSync(akmPath, 'OPENAI_API_KEY=migrated-akm-value\n');
218
-
219
- // exists() must report the user-scope secret as present.
220
- expect(await backend.exists('openpalm/openai/api-key')).toBe(true);
221
-
222
- // list() must enumerate it with present: true.
223
- const entries = await backend.list('openpalm/openai/');
224
- const openai = entries.find((e) => e.key === 'openpalm/openai/api-key');
225
- expect(openai).toBeDefined();
226
- expect(openai?.scope).toBe('user');
227
- expect(openai?.present).toBe(true);
228
- });
229
- });
230
-
231
- describe('pass backend (via detectSecretBackend)', () => {
232
- test('reports pass provider when configured', () => {
233
- const state = createState();
234
- writeSecretProviderConfig(state, {
235
- provider: 'pass',
236
- passwordStoreDir: '/tmp/test-pass-store',
237
- passPrefix: 'myprefix',
238
- });
239
-
240
- const backend = detectSecretBackend(state);
241
- expect(backend.provider).toBe('pass');
242
- expect(backend.capabilities.generate).toBe(true);
243
- });
244
-
245
- test('uses default store dir when no config', () => {
246
- const state = createState();
247
- writeSecretProviderConfig(state, { provider: 'pass' });
248
- const backend = detectSecretBackend(state);
249
- expect(backend.provider).toBe('pass');
250
- });
251
-
252
- test('exists returns false for non-existent entries', async () => {
253
- const state = createState();
254
- const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
255
- mkdirSync(storeDir, { recursive: true });
256
- writeSecretProviderConfig(state, { provider: 'pass', passwordStoreDir: storeDir });
257
-
258
- const backend = detectSecretBackend(state);
259
- expect(await backend.exists('openpalm/nonexistent')).toBe(false);
260
- });
261
-
262
- test('list returns empty array for empty store', async () => {
263
- const state = createState();
264
- const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
265
- mkdirSync(storeDir, { recursive: true });
266
- writeSecretProviderConfig(state, { provider: 'pass', passwordStoreDir: storeDir });
267
-
268
- const backend = detectSecretBackend(state);
269
- const entries = await backend.list();
270
- expect(entries).toEqual([]);
271
- });
272
-
273
- test('list scopes to passPrefix subdirectory', async () => {
274
- const state = createState();
275
- const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
276
-
277
- // Create fake .gpg files under the prefix subdirectory
278
- const prefixDir = join(storeDir, 'myprefix', 'openpalm');
279
- mkdirSync(prefixDir, { recursive: true });
280
- writeFileSync(join(prefixDir, 'admin-token.gpg'), 'fake-gpg-data');
281
- writeFileSync(join(prefixDir, 'assistant-token.gpg'), 'fake-gpg-data');
282
-
283
- // Create a file outside the prefix (should not appear)
284
- mkdirSync(join(storeDir, 'other'), { recursive: true });
285
- writeFileSync(join(storeDir, 'other', 'secret.gpg'), 'fake');
286
-
287
- writeSecretProviderConfig(state, {
288
- provider: 'pass',
289
- passwordStoreDir: storeDir,
290
- passPrefix: 'myprefix',
291
- });
292
-
293
- const backend = detectSecretBackend(state);
294
- const entries = await backend.list();
295
-
296
- expect(entries).toHaveLength(2);
297
- // Keys should be canonical (without prefix)
298
- expect(entries[0]?.key).toBe('openpalm/admin-token');
299
- expect(entries[1]?.key).toBe('openpalm/assistant-token');
300
- });
301
-
302
- test('exists checks prefixed path in store', async () => {
303
- const state = createState();
304
- const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
305
- const prefixDir = join(storeDir, 'myprefix');
306
- mkdirSync(join(prefixDir, 'openpalm'), { recursive: true });
307
- writeFileSync(join(prefixDir, 'openpalm', 'admin-token.gpg'), 'fake');
308
-
309
- writeSecretProviderConfig(state, {
310
- provider: 'pass',
311
- passwordStoreDir: storeDir,
312
- passPrefix: 'myprefix',
313
- });
314
-
315
- const backend = detectSecretBackend(state);
316
- expect(await backend.exists('openpalm/admin-token')).toBe(true);
317
- expect(await backend.exists('openpalm/nonexistent')).toBe(false);
318
- });
319
- });
320
-
321
- describe('detectSecretBackend', () => {
322
- test('returns plaintext provider by default', () => {
323
- const state = createState();
324
- const backend = detectSecretBackend(state);
325
- expect(backend.provider).toBe('plaintext');
326
- });
327
-
328
- test('returns pass provider when provider.json has provider: pass', () => {
329
- const state = createState();
330
- writeSecretProviderConfig(state, {
331
- provider: 'pass',
332
- passwordStoreDir: '/tmp/test',
333
- });
334
-
335
- const backend = detectSecretBackend(state);
336
- expect(backend.provider).toBe('pass');
337
- });
338
-
339
- test('returns plaintext provider when provider.json has provider: plaintext', () => {
340
- const state = createState();
341
- writeSecretProviderConfig(state, { provider: 'plaintext' });
342
-
343
- const backend = detectSecretBackend(state);
344
- expect(backend.provider).toBe('plaintext');
345
- });
346
- });