@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.
- 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 +48 -11
- package/src/control-plane/core-assets.ts +63 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/env.ts +4 -1
- package/src/control-plane/install-edge-cases.test.ts +3 -3
- package/src/control-plane/lifecycle.ts +31 -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 +45 -9
- package/src/index.ts +13 -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
|
@@ -9,6 +9,7 @@ import type { StackSpec } from "./stack-spec.js";
|
|
|
9
9
|
import { SPEC_DEFAULTS } from "./stack-spec.js";
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { mergeEnvContent } from "./env.js";
|
|
12
|
+
import { resolveOperatorIds } from "./operator-ids.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Derive the system.env key-value pairs from the StackSpec.
|
|
@@ -18,9 +19,6 @@ export function deriveSystemEnvFromSpec(
|
|
|
18
19
|
spec: StackSpec,
|
|
19
20
|
homeDir: string,
|
|
20
21
|
): Record<string, string> {
|
|
21
|
-
const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
|
|
22
|
-
const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
|
|
23
|
-
|
|
24
22
|
const ports = SPEC_DEFAULTS.ports;
|
|
25
23
|
const image = SPEC_DEFAULTS.image;
|
|
26
24
|
|
|
@@ -28,17 +26,25 @@ export function deriveSystemEnvFromSpec(
|
|
|
28
26
|
|
|
29
27
|
// Paths
|
|
30
28
|
result["OP_HOME"] = homeDir;
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
|
|
30
|
+
// Operator UID/GID — auto-detect from OP_HOME owner (or process UID
|
|
31
|
+
// as fallback). Skipped on Windows where containers run in WSL2 and
|
|
32
|
+
// OP_UID has no meaning on the host process.
|
|
33
|
+
const ids = resolveOperatorIds(homeDir);
|
|
34
|
+
if (ids) {
|
|
35
|
+
result["OP_UID"] = String(ids.uid);
|
|
36
|
+
result["OP_GID"] = String(ids.gid);
|
|
37
|
+
}
|
|
33
38
|
// Image
|
|
34
39
|
result["OP_IMAGE_NAMESPACE"] = image.namespace;
|
|
35
40
|
result["OP_IMAGE_TAG"] = image.tag;
|
|
36
41
|
|
|
37
|
-
// Ports
|
|
42
|
+
// Ports — only the services that publish to the host. Guardian is
|
|
43
|
+
// network-only (no host port mapping) so OP_GUARDIAN_PORT is no longer
|
|
44
|
+
// emitted; channels reach it via Docker DNS at http://guardian:8080.
|
|
38
45
|
result["OP_ASSISTANT_PORT"] = String(ports.assistant);
|
|
39
46
|
result["OP_ADMIN_PORT"] = String(ports.admin);
|
|
40
47
|
result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
|
|
41
|
-
result["OP_GUARDIAN_PORT"] = String(ports.guardian);
|
|
42
48
|
result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
|
|
43
49
|
|
|
44
50
|
void spec; // spec reserved for future use; ports/image come from SPEC_DEFAULTS
|
|
@@ -79,20 +85,25 @@ export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void
|
|
|
79
85
|
const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
80
86
|
const vars: Record<string, string> = {};
|
|
81
87
|
|
|
88
|
+
// OP_ prefix is mandatory: unprefixed TTS_*/STT_* names collide with
|
|
89
|
+
// other tooling (OpenAI clients, kokoro-fastapi, etc.) commonly set in
|
|
90
|
+
// operator shells. The UI server only reads OP_-prefixed vars from
|
|
91
|
+
// process.env, so a leaked host TTS_VOICE can't silently override the
|
|
92
|
+
// saved selection.
|
|
82
93
|
const { tts, stt } = config;
|
|
83
94
|
if (tts?.enabled !== false) {
|
|
84
|
-
if (tts?.engine) vars["
|
|
85
|
-
if (tts?.provider) vars["
|
|
86
|
-
if (tts?.baseURL) vars["
|
|
87
|
-
if (tts?.model) vars["
|
|
88
|
-
if (tts?.voice) vars["
|
|
95
|
+
if (tts?.engine) vars["OP_TTS_ENGINE"] = tts.engine;
|
|
96
|
+
if (tts?.provider) vars["OP_TTS_PROVIDER"] = tts.provider;
|
|
97
|
+
if (tts?.baseURL) vars["OP_TTS_BASE_URL"] = tts.baseURL;
|
|
98
|
+
if (tts?.model) vars["OP_TTS_MODEL"] = tts.model;
|
|
99
|
+
if (tts?.voice) vars["OP_TTS_VOICE"] = tts.voice;
|
|
89
100
|
}
|
|
90
101
|
if (stt?.enabled !== false) {
|
|
91
|
-
if (stt?.engine) vars["
|
|
92
|
-
if (stt?.provider) vars["
|
|
93
|
-
if (stt?.baseURL) vars["
|
|
94
|
-
if (stt?.model) vars["
|
|
95
|
-
if (stt?.language) vars["
|
|
102
|
+
if (stt?.engine) vars["OP_STT_ENGINE"] = stt.engine;
|
|
103
|
+
if (stt?.provider) vars["OP_STT_PROVIDER"] = stt.provider;
|
|
104
|
+
if (stt?.baseURL) vars["OP_STT_BASE_URL"] = stt.baseURL;
|
|
105
|
+
if (stt?.model) vars["OP_STT_MODEL"] = stt.model;
|
|
106
|
+
if (stt?.language) vars["OP_STT_LANGUAGE"] = stt.language;
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
if (Object.keys(vars).length === 0) return;
|
|
@@ -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.
|
|
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
|
-
//
|
|
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/ —
|
|
181
|
-
* 2.
|
|
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
|
-
|
|
186
|
-
|
|
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
|
/**
|
|
@@ -216,14 +241,21 @@ function parseChecksumsFile(content: string): Map<string, string> {
|
|
|
216
241
|
return map;
|
|
217
242
|
}
|
|
218
243
|
|
|
219
|
-
export
|
|
244
|
+
export function readCurrentUiBuildVersion(stateDir: string): string | null {
|
|
245
|
+
const versionFile = join(stateDir, 'ui', 'version.txt');
|
|
246
|
+
if (!existsSync(versionFile)) return null;
|
|
247
|
+
return readFileSync(versionFile, 'utf-8').trim() || null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function seedUiBuild(repoRef: string, stateDir: string, options?: { forceRemote?: boolean }): Promise<void> {
|
|
220
251
|
const uiDir = join(stateDir, 'ui');
|
|
221
252
|
mkdirSync(uiDir, { recursive: true });
|
|
222
253
|
|
|
223
|
-
const local = resolveLocalUiBuild();
|
|
254
|
+
const local = options?.forceRemote ? null : resolveLocalUiBuild();
|
|
224
255
|
if (local) {
|
|
225
256
|
logger.debug('seeding UI build from local source', { src: local });
|
|
226
257
|
copyTree(local, uiDir);
|
|
258
|
+
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
227
259
|
return;
|
|
228
260
|
}
|
|
229
261
|
|
|
@@ -258,8 +290,12 @@ export async function seedUiBuild(repoRef: string, stateDir: string): Promise<vo
|
|
|
258
290
|
|
|
259
291
|
writeFileSync(tmpTar, tarData);
|
|
260
292
|
|
|
293
|
+
// Clear stale files before extracting so old build files don't persist
|
|
294
|
+
rmSync(uiDir, { recursive: true, force: true });
|
|
295
|
+
mkdirSync(uiDir, { recursive: true });
|
|
261
296
|
// Cross-platform extraction via the `tar` npm package — no shell dependency
|
|
262
297
|
await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
|
|
298
|
+
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
263
299
|
} finally {
|
|
264
300
|
rmSync(tmpTar, { force: true });
|
|
265
301
|
}
|
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 ────────────────────────────────────────────
|
|
@@ -186,6 +181,7 @@ export {
|
|
|
186
181
|
applyUninstall,
|
|
187
182
|
applyUpgrade,
|
|
188
183
|
performUpgrade,
|
|
184
|
+
applyTagChange,
|
|
189
185
|
updateStackEnvToLatestImageTag,
|
|
190
186
|
buildComposeFileList,
|
|
191
187
|
buildManagedServices,
|
|
@@ -259,6 +255,13 @@ export {
|
|
|
259
255
|
writeVoiceVars,
|
|
260
256
|
} from "./control-plane/spec-to-env.js";
|
|
261
257
|
|
|
258
|
+
// ── Operator UID/GID Detection ──────────────────────────────────────────
|
|
259
|
+
export type { OperatorIds } from "./control-plane/operator-ids.js";
|
|
260
|
+
export {
|
|
261
|
+
resolveOperatorIds,
|
|
262
|
+
hasUsableOperatorId,
|
|
263
|
+
} from "./control-plane/operator-ids.js";
|
|
264
|
+
|
|
262
265
|
// ── Setup ────────────────────────────────────────────────────────────────
|
|
263
266
|
export type {
|
|
264
267
|
SetupSpec,
|
|
@@ -306,4 +309,5 @@ export {
|
|
|
306
309
|
resolveUiBuildDir,
|
|
307
310
|
seedUiBuild,
|
|
308
311
|
checkAndUpdateUiBuild,
|
|
312
|
+
readCurrentUiBuildVersion,
|
|
309
313
|
} from "./control-plane/ui-assets.js";
|
package/src/logger.test.ts
CHANGED
|
@@ -34,7 +34,7 @@ describe('redactValue', () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
test('leaves non-secret values alone', () => {
|
|
37
|
-
expect(redactValue('
|
|
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('
|
|
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
|
-
|
|
84
|
+
OP_OWNER_NAME: 'alice',
|
|
85
85
|
});
|
|
86
86
|
expect(result).toEqual({
|
|
87
87
|
OPENAI_API_KEY: '***REDACTED***',
|
|
88
|
-
|
|
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
|
-
|
|
96
|
+
OP_OWNER_NAME: 'alice',
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
99
|
expect(result).toEqual({
|
|
100
100
|
env: {
|
|
101
101
|
OPENAI_API_KEY: '***REDACTED***',
|
|
102
|
-
|
|
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
|
-
{
|
|
111
|
+
{ OP_OWNER_NAME: 'bob' },
|
|
112
112
|
],
|
|
113
113
|
});
|
|
114
114
|
expect(result).toEqual({
|
|
115
115
|
items: [
|
|
116
116
|
{ OPENAI_API_KEY: '***REDACTED***' },
|
|
117
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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('"
|
|
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
|
-
});
|