@openpalm/lib 0.10.2 → 0.11.0-beta.2

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 (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  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 +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  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 +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  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/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { appendFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
@@ -8,39 +8,35 @@ import {
8
8
  ensureSecrets,
9
9
  validatePassEntryName,
10
10
  } from '../index.js';
11
- import { PlaintextBackend, PassBackend } from './secret-backend.js';
12
- import { generateRedactSchema } from './redact-schema.js';
13
- import { getCoreSecretMappings } from './secret-mappings.js';
14
11
  import { writeSecretProviderConfig } from './provider-config.js';
12
+ import { akmUserVaultPathSync } from './akm-vault.js';
13
+ import { dirname } from 'node:path';
15
14
 
16
15
  let rootDir = '';
17
16
 
18
17
  function createState(): ControlPlaneState {
19
- const vaultDir = join(rootDir, 'vault');
20
- const dataDir = join(rootDir, 'data');
18
+ const stateDir = join(rootDir, 'state');
21
19
  const configDir = join(rootDir, 'config');
22
- const logsDir = join(rootDir, 'logs');
20
+ const stackDir = join(configDir, 'stack');
23
21
  const cacheDir = join(rootDir, 'cache');
24
- mkdirSync(vaultDir, { recursive: true });
25
- mkdirSync(dataDir, { recursive: true });
22
+ mkdirSync(stateDir, { recursive: true });
23
+ mkdirSync(stackDir, { recursive: true });
26
24
  mkdirSync(configDir, { recursive: true });
27
- mkdirSync(logsDir, { recursive: true });
25
+ mkdirSync(join(rootDir, 'stash'), { recursive: true });
26
+ mkdirSync(join(rootDir, 'workspace'), { recursive: true });
28
27
  mkdirSync(cacheDir, { recursive: true });
29
28
 
30
29
  return {
31
- adminToken: 'admin-token',
32
- assistantToken: '',
33
- setupToken: 'setup-token',
34
30
  homeDir: rootDir,
35
31
  configDir,
36
- vaultDir,
37
- dataDir,
38
- logsDir,
32
+ stashDir: join(rootDir, 'stash'),
33
+ workspaceDir: join(rootDir, 'workspace'),
39
34
  cacheDir,
35
+ stateDir,
36
+ stackDir,
40
37
  services: {},
41
38
  artifacts: { compose: '' },
42
39
  artifactMeta: [],
43
- audit: [],
44
40
  };
45
41
  }
46
42
 
@@ -55,11 +51,11 @@ afterEach(() => {
55
51
  describe('secret backend', () => {
56
52
  test('ensureSecrets repairs auth.json when Docker created it as a directory', () => {
57
53
  const state = createState();
58
- mkdirSync(join(state.vaultDir, 'stack', 'auth.json'), { recursive: true });
54
+ mkdirSync(join(state.configDir, "auth.json"), { recursive: true });
59
55
 
60
56
  ensureSecrets(state);
61
57
 
62
- const authJsonPath = join(state.vaultDir, 'stack', 'auth.json');
58
+ const authJsonPath = join(state.configDir, "auth.json");
63
59
  expect(lstatSync(authJsonPath).isFile()).toBe(true);
64
60
  expect(readFileSync(authJsonPath, 'utf-8')).toBe('{}\n');
65
61
  });
@@ -70,6 +66,9 @@ describe('secret backend', () => {
70
66
  const backend = detectSecretBackend(state);
71
67
 
72
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);
73
72
 
74
73
  const entry = await backend.write('openpalm/custom/example', 'very-secret');
75
74
  expect(entry.provider).toBe('plaintext');
@@ -77,7 +76,7 @@ describe('secret backend', () => {
77
76
  expect(await backend.exists('openpalm/custom/example')).toBe(true);
78
77
 
79
78
  // Custom secrets are now written to stack.env (all secrets consolidated there)
80
- const stackEnv = readFileSync(join(state.vaultDir, 'stack', 'stack.env'), 'utf-8');
79
+ const stackEnv = readFileSync(join(state.stackDir, "stack.env"), 'utf-8');
81
80
  expect(stackEnv).toContain('very-secret');
82
81
  });
83
82
 
@@ -109,11 +108,11 @@ describe('secret backend', () => {
109
108
  });
110
109
  });
111
110
 
112
- describe('PlaintextBackend', () => {
111
+ describe('plaintext backend (via detectSecretBackend)', () => {
113
112
  test('remove clears value for non-core secrets', async () => {
114
113
  const state = createState();
115
114
  ensureSecrets(state);
116
- const backend = new PlaintextBackend(state);
115
+ const backend = detectSecretBackend(state);
117
116
 
118
117
  await backend.write('openpalm/custom/temp', 'temp-value');
119
118
  expect(await backend.exists('openpalm/custom/temp')).toBe(true);
@@ -132,7 +131,7 @@ describe('PlaintextBackend', () => {
132
131
  test('remove clears value but keeps index for core secrets', async () => {
133
132
  const state = createState();
134
133
  ensureSecrets(state);
135
- const backend = new PlaintextBackend(state);
134
+ const backend = detectSecretBackend(state);
136
135
 
137
136
  // Write a core secret
138
137
  await backend.write('openpalm/admin-token', 'my-token');
@@ -150,7 +149,7 @@ describe('PlaintextBackend', () => {
150
149
  test('list includes both core and indexed entries', async () => {
151
150
  const state = createState();
152
151
  ensureSecrets(state);
153
- const backend = new PlaintextBackend(state);
152
+ const backend = detectSecretBackend(state);
154
153
 
155
154
  await backend.write('openpalm/custom/my-key', 'value');
156
155
 
@@ -166,16 +165,71 @@ describe('PlaintextBackend', () => {
166
165
  test('generate creates a secret with random value', async () => {
167
166
  const state = createState();
168
167
  ensureSecrets(state);
169
- const backend = new PlaintextBackend(state);
168
+ const backend = detectSecretBackend(state);
170
169
 
171
170
  const entry = await backend.generate('openpalm/custom/generated', 64);
172
171
  expect(entry.present).toBe(true);
173
172
  expect(await backend.exists('openpalm/custom/generated')).toBe(true);
174
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
+ });
175
229
  });
176
230
 
177
- describe('PassBackend', () => {
178
- test('constructor reads passPrefix from provider config', () => {
231
+ describe('pass backend (via detectSecretBackend)', () => {
232
+ test('reports pass provider when configured', () => {
179
233
  const state = createState();
180
234
  writeSecretProviderConfig(state, {
181
235
  provider: 'pass',
@@ -183,40 +237,42 @@ describe('PassBackend', () => {
183
237
  passPrefix: 'myprefix',
184
238
  });
185
239
 
186
- const backend = new PassBackend(state);
240
+ const backend = detectSecretBackend(state);
187
241
  expect(backend.provider).toBe('pass');
188
- // Verify it doesn't throw with valid config
189
242
  expect(backend.capabilities.generate).toBe(true);
190
243
  });
191
244
 
192
- test('constructor uses default store dir when no config', () => {
245
+ test('uses default store dir when no config', () => {
193
246
  const state = createState();
194
- const backend = new PassBackend(state);
247
+ writeSecretProviderConfig(state, { provider: 'pass' });
248
+ const backend = detectSecretBackend(state);
195
249
  expect(backend.provider).toBe('pass');
196
250
  });
197
251
 
198
252
  test('exists returns false for non-existent entries', async () => {
199
253
  const state = createState();
200
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
254
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
201
255
  mkdirSync(storeDir, { recursive: true });
256
+ writeSecretProviderConfig(state, { provider: 'pass', passwordStoreDir: storeDir });
202
257
 
203
- const backend = new PassBackend(state);
258
+ const backend = detectSecretBackend(state);
204
259
  expect(await backend.exists('openpalm/nonexistent')).toBe(false);
205
260
  });
206
261
 
207
262
  test('list returns empty array for empty store', async () => {
208
263
  const state = createState();
209
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
264
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
210
265
  mkdirSync(storeDir, { recursive: true });
266
+ writeSecretProviderConfig(state, { provider: 'pass', passwordStoreDir: storeDir });
211
267
 
212
- const backend = new PassBackend(state);
268
+ const backend = detectSecretBackend(state);
213
269
  const entries = await backend.list();
214
270
  expect(entries).toEqual([]);
215
271
  });
216
272
 
217
273
  test('list scopes to passPrefix subdirectory', async () => {
218
274
  const state = createState();
219
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
275
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
220
276
 
221
277
  // Create fake .gpg files under the prefix subdirectory
222
278
  const prefixDir = join(storeDir, 'myprefix', 'openpalm');
@@ -234,7 +290,7 @@ describe('PassBackend', () => {
234
290
  passPrefix: 'myprefix',
235
291
  });
236
292
 
237
- const backend = new PassBackend(state);
293
+ const backend = detectSecretBackend(state);
238
294
  const entries = await backend.list();
239
295
 
240
296
  expect(entries).toHaveLength(2);
@@ -245,7 +301,7 @@ describe('PassBackend', () => {
245
301
 
246
302
  test('exists checks prefixed path in store', async () => {
247
303
  const state = createState();
248
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
304
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
249
305
  const prefixDir = join(storeDir, 'myprefix');
250
306
  mkdirSync(join(prefixDir, 'openpalm'), { recursive: true });
251
307
  writeFileSync(join(prefixDir, 'openpalm', 'admin-token.gpg'), 'fake');
@@ -256,21 +312,20 @@ describe('PassBackend', () => {
256
312
  passPrefix: 'myprefix',
257
313
  });
258
314
 
259
- const backend = new PassBackend(state);
315
+ const backend = detectSecretBackend(state);
260
316
  expect(await backend.exists('openpalm/admin-token')).toBe(true);
261
317
  expect(await backend.exists('openpalm/nonexistent')).toBe(false);
262
318
  });
263
319
  });
264
320
 
265
321
  describe('detectSecretBackend', () => {
266
- test('returns PlaintextBackend by default', () => {
322
+ test('returns plaintext provider by default', () => {
267
323
  const state = createState();
268
324
  const backend = detectSecretBackend(state);
269
325
  expect(backend.provider).toBe('plaintext');
270
- expect(backend).toBeInstanceOf(PlaintextBackend);
271
326
  });
272
327
 
273
- test('returns PassBackend when provider.json has provider: pass', () => {
328
+ test('returns pass provider when provider.json has provider: pass', () => {
274
329
  const state = createState();
275
330
  writeSecretProviderConfig(state, {
276
331
  provider: 'pass',
@@ -279,81 +334,13 @@ describe('detectSecretBackend', () => {
279
334
 
280
335
  const backend = detectSecretBackend(state);
281
336
  expect(backend.provider).toBe('pass');
282
- expect(backend).toBeInstanceOf(PassBackend);
283
337
  });
284
338
 
285
- test('returns PassBackend when schema contains @varlock/pass-plugin', () => {
286
- const state = createState();
287
- mkdirSync(join(state.vaultDir, 'user'), { recursive: true });
288
- writeFileSync(
289
- join(state.vaultDir, 'user', 'user.env.schema'),
290
- '# @plugin(@varlock/pass-plugin)\nOPENAI_API_KEY=pass("openpalm/openai/api-key")\n',
291
- );
292
-
293
- const backend = detectSecretBackend(state);
294
- expect(backend.provider).toBe('pass');
295
- expect(backend).toBeInstanceOf(PassBackend);
296
- });
297
-
298
- test('returns PlaintextBackend when provider.json has provider: plaintext', () => {
339
+ test('returns plaintext provider when provider.json has provider: plaintext', () => {
299
340
  const state = createState();
300
341
  writeSecretProviderConfig(state, { provider: 'plaintext' });
301
342
 
302
343
  const backend = detectSecretBackend(state);
303
344
  expect(backend.provider).toBe('plaintext');
304
- expect(backend).toBeInstanceOf(PlaintextBackend);
305
- });
306
- });
307
-
308
- describe('generateRedactSchema', () => {
309
- test('output includes all mapped env keys', () => {
310
- const systemEnv: Record<string, string> = {};
311
- const schema = generateRedactSchema(systemEnv);
312
-
313
- // All static core mappings should be present
314
- expect(schema).toContain('OP_ADMIN_TOKEN=');
315
- expect(schema).toContain('OP_ASSISTANT_TOKEN=');
316
- expect(schema).toContain('OP_MEMORY_TOKEN=');
317
- expect(schema).toContain('OPENAI_API_KEY=');
318
- expect(schema).toContain('ANTHROPIC_API_KEY=');
319
- expect(schema).toContain('GROQ_API_KEY=');
320
- expect(schema).toContain('MISTRAL_API_KEY=');
321
- expect(schema).toContain('GOOGLE_API_KEY=');
322
- expect(schema).toContain('MCP_API_KEY=');
323
- expect(schema).toContain('EMBEDDING_API_KEY=');
324
- });
325
-
326
- test('includes legacy aliases', () => {
327
- const schema = generateRedactSchema({});
328
- expect(schema).toContain('ADMIN_TOKEN=');
329
- expect(schema).toContain('OP_OPENCODE_PASSWORD=');
330
- });
331
-
332
- test('includes dynamic channel secrets', () => {
333
- const systemEnv = {
334
- CHANNEL_DISCORD_SECRET: 'abc123',
335
- CHANNEL_SLACK_SECRET: 'def456',
336
- };
337
- const schema = generateRedactSchema(systemEnv);
338
- expect(schema).toContain('CHANNEL_DISCORD_SECRET=');
339
- expect(schema).toContain('CHANNEL_SLACK_SECRET=');
340
- });
341
-
342
- test('has correct header format', () => {
343
- const schema = generateRedactSchema({});
344
- expect(schema).toContain('@defaultSensitive=true');
345
- expect(schema).toContain('@defaultRequired=false');
346
- });
347
-
348
- test('entries are sorted', () => {
349
- const schema = generateRedactSchema({});
350
- const lines = schema
351
- .split('\n')
352
- .filter((l) => l.match(/^[A-Z]/))
353
- .map((l) => l.replace(/=.*$/, ''));
354
-
355
- const sorted = [...lines].sort();
356
- expect(lines).toEqual(sorted);
357
345
  });
358
346
  });
359
-