@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.
- package/README.md +2 -0
- package/package.json +4 -1
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -9
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/config-persistence.ts +46 -10
- package/src/control-plane/core-assets.ts +48 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/install-edge-cases.test.ts +3 -3
- package/src/control-plane/lifecycle.ts +13 -9
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/registry.test.ts +134 -4
- package/src/control-plane/registry.ts +220 -4
- package/src/control-plane/secrets.ts +4 -4
- package/src/control-plane/setup.test.ts +6 -6
- package/src/control-plane/setup.ts +4 -6
- package/src/control-plane/spec-to-env.test.ts +25 -9
- package/src/control-plane/spec-to-env.ts +28 -17
- package/src/control-plane/types.ts +0 -4
- package/src/control-plane/ui-assets.ts +34 -7
- package/src/index.ts +11 -9
- package/src/logger.test.ts +12 -12
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -346
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -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
|
-
});
|