@openpalm/lib 0.10.1 → 0.11.0-beta.1

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 (55) 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 +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. 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,35 +8,34 @@ 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
30
  adminToken: 'admin-token',
32
31
  assistantToken: '',
33
- setupToken: 'setup-token',
34
32
  homeDir: rootDir,
35
33
  configDir,
36
- vaultDir,
37
- dataDir,
38
- logsDir,
34
+ stashDir: join(rootDir, 'stash'),
35
+ workspaceDir: join(rootDir, 'workspace'),
39
36
  cacheDir,
37
+ stateDir,
38
+ stackDir,
40
39
  services: {},
41
40
  artifacts: { compose: '' },
42
41
  artifactMeta: [],
@@ -55,11 +54,11 @@ afterEach(() => {
55
54
  describe('secret backend', () => {
56
55
  test('ensureSecrets repairs auth.json when Docker created it as a directory', () => {
57
56
  const state = createState();
58
- mkdirSync(join(state.vaultDir, 'stack', 'auth.json'), { recursive: true });
57
+ mkdirSync(join(state.configDir, "auth.json"), { recursive: true });
59
58
 
60
59
  ensureSecrets(state);
61
60
 
62
- const authJsonPath = join(state.vaultDir, 'stack', 'auth.json');
61
+ const authJsonPath = join(state.configDir, "auth.json");
63
62
  expect(lstatSync(authJsonPath).isFile()).toBe(true);
64
63
  expect(readFileSync(authJsonPath, 'utf-8')).toBe('{}\n');
65
64
  });
@@ -70,6 +69,9 @@ describe('secret backend', () => {
70
69
  const backend = detectSecretBackend(state);
71
70
 
72
71
  expect(backend.provider).toBe('plaintext');
72
+ expect(backend.capabilities.generate).toBe(true);
73
+ expect(backend.capabilities.remove).toBe(true);
74
+ expect(backend.capabilities.rename).toBe(false);
73
75
 
74
76
  const entry = await backend.write('openpalm/custom/example', 'very-secret');
75
77
  expect(entry.provider).toBe('plaintext');
@@ -77,7 +79,7 @@ describe('secret backend', () => {
77
79
  expect(await backend.exists('openpalm/custom/example')).toBe(true);
78
80
 
79
81
  // Custom secrets are now written to stack.env (all secrets consolidated there)
80
- const stackEnv = readFileSync(join(state.vaultDir, 'stack', 'stack.env'), 'utf-8');
82
+ const stackEnv = readFileSync(join(state.stackDir, "stack.env"), 'utf-8');
81
83
  expect(stackEnv).toContain('very-secret');
82
84
  });
83
85
 
@@ -109,11 +111,11 @@ describe('secret backend', () => {
109
111
  });
110
112
  });
111
113
 
112
- describe('PlaintextBackend', () => {
114
+ describe('plaintext backend (via detectSecretBackend)', () => {
113
115
  test('remove clears value for non-core secrets', async () => {
114
116
  const state = createState();
115
117
  ensureSecrets(state);
116
- const backend = new PlaintextBackend(state);
118
+ const backend = detectSecretBackend(state);
117
119
 
118
120
  await backend.write('openpalm/custom/temp', 'temp-value');
119
121
  expect(await backend.exists('openpalm/custom/temp')).toBe(true);
@@ -132,7 +134,7 @@ describe('PlaintextBackend', () => {
132
134
  test('remove clears value but keeps index for core secrets', async () => {
133
135
  const state = createState();
134
136
  ensureSecrets(state);
135
- const backend = new PlaintextBackend(state);
137
+ const backend = detectSecretBackend(state);
136
138
 
137
139
  // Write a core secret
138
140
  await backend.write('openpalm/admin-token', 'my-token');
@@ -150,7 +152,7 @@ describe('PlaintextBackend', () => {
150
152
  test('list includes both core and indexed entries', async () => {
151
153
  const state = createState();
152
154
  ensureSecrets(state);
153
- const backend = new PlaintextBackend(state);
155
+ const backend = detectSecretBackend(state);
154
156
 
155
157
  await backend.write('openpalm/custom/my-key', 'value');
156
158
 
@@ -166,16 +168,71 @@ describe('PlaintextBackend', () => {
166
168
  test('generate creates a secret with random value', async () => {
167
169
  const state = createState();
168
170
  ensureSecrets(state);
169
- const backend = new PlaintextBackend(state);
171
+ const backend = detectSecretBackend(state);
170
172
 
171
173
  const entry = await backend.generate('openpalm/custom/generated', 64);
172
174
  expect(entry.present).toBe(true);
173
175
  expect(await backend.exists('openpalm/custom/generated')).toBe(true);
174
176
  });
177
+
178
+ test('user-scope reads from akm vault, system-scope reads from stack.env', async () => {
179
+ // Regression test: user scope must consult the akm vault file, system scope
180
+ // must consult state/stack.env. When both files define the same key with
181
+ // different values, the two scopes must return their own file's value.
182
+ const state = createState();
183
+ ensureSecrets(state);
184
+ const backend = detectSecretBackend(state);
185
+
186
+ // Seed the akm vault file with a user-scope value.
187
+ const akmPath = akmUserVaultPathSync(state);
188
+ mkdirSync(dirname(akmPath), { recursive: true });
189
+ writeFileSync(akmPath, 'OPENAI_API_KEY=akm-vault-openai\n');
190
+
191
+ // Stack.env already exists from ensureSecrets — seed a system token.
192
+ const stackEnvPath = join(state.stackDir, "stack.env");
193
+ const stackContent = readFileSync(stackEnvPath, 'utf-8')
194
+ .replace(/^OP_UI_TOKEN=.*$/m, 'OP_UI_TOKEN=stack-admin-token');
195
+ writeFileSync(stackEnvPath, stackContent);
196
+
197
+ // System scope reads stack.env exclusively.
198
+ expect(await backend.exists('openpalm/admin-token')).toBe(true);
199
+ const systemEntries = await backend.list('openpalm/admin-token');
200
+ expect(systemEntries.find((e) => e.key === 'openpalm/admin-token')?.present).toBe(true);
201
+
202
+ // User scope reads akm vault file.
203
+ const userEntries = await backend.list('openpalm/openai/');
204
+ const openai = userEntries.find((e) => e.key === 'openpalm/openai/api-key');
205
+ expect(openai).toBeDefined();
206
+ expect(openai?.scope).toBe('user');
207
+ expect(openai?.present).toBe(true);
208
+ });
209
+
210
+ test('list/exists resolve user-scope secrets from akm vault', async () => {
211
+ // The backend MUST resolve user-managed secrets through the akm vault file
212
+ // (stash/vaults/user.env), not from any legacy path.
213
+ const state = createState();
214
+ ensureSecrets(state);
215
+ const backend = detectSecretBackend(state);
216
+
217
+ // Place the secret in the akm vault file.
218
+ const akmPath = akmUserVaultPathSync(state);
219
+ mkdirSync(dirname(akmPath), { recursive: true });
220
+ writeFileSync(akmPath, 'OPENAI_API_KEY=migrated-akm-value\n');
221
+
222
+ // exists() must report the user-scope secret as present.
223
+ expect(await backend.exists('openpalm/openai/api-key')).toBe(true);
224
+
225
+ // list() must enumerate it with present: true.
226
+ const entries = await backend.list('openpalm/openai/');
227
+ const openai = entries.find((e) => e.key === 'openpalm/openai/api-key');
228
+ expect(openai).toBeDefined();
229
+ expect(openai?.scope).toBe('user');
230
+ expect(openai?.present).toBe(true);
231
+ });
175
232
  });
176
233
 
177
- describe('PassBackend', () => {
178
- test('constructor reads passPrefix from provider config', () => {
234
+ describe('pass backend (via detectSecretBackend)', () => {
235
+ test('reports pass provider when configured', () => {
179
236
  const state = createState();
180
237
  writeSecretProviderConfig(state, {
181
238
  provider: 'pass',
@@ -183,40 +240,42 @@ describe('PassBackend', () => {
183
240
  passPrefix: 'myprefix',
184
241
  });
185
242
 
186
- const backend = new PassBackend(state);
243
+ const backend = detectSecretBackend(state);
187
244
  expect(backend.provider).toBe('pass');
188
- // Verify it doesn't throw with valid config
189
245
  expect(backend.capabilities.generate).toBe(true);
190
246
  });
191
247
 
192
- test('constructor uses default store dir when no config', () => {
248
+ test('uses default store dir when no config', () => {
193
249
  const state = createState();
194
- const backend = new PassBackend(state);
250
+ writeSecretProviderConfig(state, { provider: 'pass' });
251
+ const backend = detectSecretBackend(state);
195
252
  expect(backend.provider).toBe('pass');
196
253
  });
197
254
 
198
255
  test('exists returns false for non-existent entries', async () => {
199
256
  const state = createState();
200
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
257
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
201
258
  mkdirSync(storeDir, { recursive: true });
259
+ writeSecretProviderConfig(state, { provider: 'pass', passwordStoreDir: storeDir });
202
260
 
203
- const backend = new PassBackend(state);
261
+ const backend = detectSecretBackend(state);
204
262
  expect(await backend.exists('openpalm/nonexistent')).toBe(false);
205
263
  });
206
264
 
207
265
  test('list returns empty array for empty store', async () => {
208
266
  const state = createState();
209
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
267
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
210
268
  mkdirSync(storeDir, { recursive: true });
269
+ writeSecretProviderConfig(state, { provider: 'pass', passwordStoreDir: storeDir });
211
270
 
212
- const backend = new PassBackend(state);
271
+ const backend = detectSecretBackend(state);
213
272
  const entries = await backend.list();
214
273
  expect(entries).toEqual([]);
215
274
  });
216
275
 
217
276
  test('list scopes to passPrefix subdirectory', async () => {
218
277
  const state = createState();
219
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
278
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
220
279
 
221
280
  // Create fake .gpg files under the prefix subdirectory
222
281
  const prefixDir = join(storeDir, 'myprefix', 'openpalm');
@@ -234,7 +293,7 @@ describe('PassBackend', () => {
234
293
  passPrefix: 'myprefix',
235
294
  });
236
295
 
237
- const backend = new PassBackend(state);
296
+ const backend = detectSecretBackend(state);
238
297
  const entries = await backend.list();
239
298
 
240
299
  expect(entries).toHaveLength(2);
@@ -245,7 +304,7 @@ describe('PassBackend', () => {
245
304
 
246
305
  test('exists checks prefixed path in store', async () => {
247
306
  const state = createState();
248
- const storeDir = join(rootDir, 'data', 'secrets', 'pass-store');
307
+ const storeDir = join(rootDir, 'state', 'secrets', 'pass-store');
249
308
  const prefixDir = join(storeDir, 'myprefix');
250
309
  mkdirSync(join(prefixDir, 'openpalm'), { recursive: true });
251
310
  writeFileSync(join(prefixDir, 'openpalm', 'admin-token.gpg'), 'fake');
@@ -256,21 +315,20 @@ describe('PassBackend', () => {
256
315
  passPrefix: 'myprefix',
257
316
  });
258
317
 
259
- const backend = new PassBackend(state);
318
+ const backend = detectSecretBackend(state);
260
319
  expect(await backend.exists('openpalm/admin-token')).toBe(true);
261
320
  expect(await backend.exists('openpalm/nonexistent')).toBe(false);
262
321
  });
263
322
  });
264
323
 
265
324
  describe('detectSecretBackend', () => {
266
- test('returns PlaintextBackend by default', () => {
325
+ test('returns plaintext provider by default', () => {
267
326
  const state = createState();
268
327
  const backend = detectSecretBackend(state);
269
328
  expect(backend.provider).toBe('plaintext');
270
- expect(backend).toBeInstanceOf(PlaintextBackend);
271
329
  });
272
330
 
273
- test('returns PassBackend when provider.json has provider: pass', () => {
331
+ test('returns pass provider when provider.json has provider: pass', () => {
274
332
  const state = createState();
275
333
  writeSecretProviderConfig(state, {
276
334
  provider: 'pass',
@@ -279,81 +337,13 @@ describe('detectSecretBackend', () => {
279
337
 
280
338
  const backend = detectSecretBackend(state);
281
339
  expect(backend.provider).toBe('pass');
282
- expect(backend).toBeInstanceOf(PassBackend);
283
- });
284
-
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
340
  });
297
341
 
298
- test('returns PlaintextBackend when provider.json has provider: plaintext', () => {
342
+ test('returns plaintext provider when provider.json has provider: plaintext', () => {
299
343
  const state = createState();
300
344
  writeSecretProviderConfig(state, { provider: 'plaintext' });
301
345
 
302
346
  const backend = detectSecretBackend(state);
303
347
  expect(backend.provider).toBe('plaintext');
304
- expect(backend).toBeInstanceOf(PlaintextBackend);
305
348
  });
306
349
  });
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
- });
358
- });
359
-