@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.10
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 +5 -1
- package/src/control-plane/akm-vault.test.ts +1 -4
- 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 -12
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +49 -13
- 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/host-opencode.test.ts +0 -3
- package/src/control-plane/install-edge-cases.test.ts +29 -69
- package/src/control-plane/lifecycle.ts +39 -50
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/paths.ts +8 -3
- package/src/control-plane/registry-components.test.ts +3 -2
- package/src/control-plane/registry.test.ts +198 -4
- package/src/control-plane/registry.ts +333 -4
- package/src/control-plane/secret-mappings.ts +2 -3
- package/src/control-plane/secrets.ts +17 -11
- package/src/control-plane/setup-config.schema.json +3 -3
- package/src/control-plane/setup-status.ts +6 -1
- package/src/control-plane/setup-validation.ts +2 -2
- package/src/control-plane/setup.test.ts +24 -20
- package/src/control-plane/setup.ts +25 -41
- package/src/control-plane/spec-to-env.test.ts +30 -16
- package/src/control-plane/spec-to-env.ts +37 -21
- package/src/control-plane/stack-spec.test.ts +5 -11
- package/src/control-plane/stack-spec.ts +2 -6
- package/src/control-plane/types.ts +0 -22
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/control-plane/validate.ts +1 -1
- package/src/index.ts +26 -13
- package/src/logger.test.ts +12 -12
- package/src/logger.ts +1 -1
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/audit.ts +0 -41
- 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 -349
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
package/src/index.ts
CHANGED
|
@@ -25,15 +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
|
-
AuditEntry,
|
|
33
31
|
} from "./control-plane/types.js";
|
|
34
32
|
export {
|
|
35
33
|
CORE_SERVICES,
|
|
36
|
-
OPTIONAL_SERVICES,
|
|
37
34
|
} from "./control-plane/types.js";
|
|
38
35
|
|
|
39
36
|
// ── Backups ───────────────────────────────────────────────────────────────
|
|
@@ -44,6 +41,8 @@ export {
|
|
|
44
41
|
// ── Registry Catalog ─────────────────────────────────────────────────────
|
|
45
42
|
export type {
|
|
46
43
|
AddonMutationResult,
|
|
44
|
+
AddonProfile,
|
|
45
|
+
AddonProfileAvailability,
|
|
47
46
|
RegistryAutomationEntry,
|
|
48
47
|
RegistryComponentEntry,
|
|
49
48
|
RegistryAddonConfig,
|
|
@@ -58,10 +57,13 @@ export {
|
|
|
58
57
|
getRegistryAutomation,
|
|
59
58
|
getRegistryAddonConfig,
|
|
60
59
|
getAddonServiceNames,
|
|
60
|
+
getAddonProfiles,
|
|
61
|
+
getAddonProfileAvailability,
|
|
62
|
+
annotateAddonProfileAvailability,
|
|
63
|
+
getAddonProfileSelection,
|
|
64
|
+
setAddonProfileSelection,
|
|
61
65
|
listAvailableAddonIds,
|
|
62
66
|
listEnabledAddonIds,
|
|
63
|
-
enableAddon,
|
|
64
|
-
disableAddonByName,
|
|
65
67
|
setAddonEnabled,
|
|
66
68
|
installAutomationFromRegistry,
|
|
67
69
|
uninstallAutomation,
|
|
@@ -96,9 +98,6 @@ export {
|
|
|
96
98
|
RELEASE_TAG_REGEX,
|
|
97
99
|
} from "./control-plane/env.js";
|
|
98
100
|
|
|
99
|
-
// ── Audit ───────────────────────────────────────────────────────────────
|
|
100
|
-
export { appendAudit } from "./control-plane/audit.js";
|
|
101
|
-
|
|
102
101
|
// ── OpenCode Client ─────────────────────────────────────────────────────
|
|
103
102
|
export { createOpenCodeClient } from "./control-plane/opencode-client.js";
|
|
104
103
|
export type { ProxyResult, OpenCodeProvider } from "./control-plane/opencode-client.js";
|
|
@@ -114,11 +113,8 @@ export {
|
|
|
114
113
|
maskSecretValue,
|
|
115
114
|
ensureOpenCodeConfig,
|
|
116
115
|
} from "./control-plane/secrets.js";
|
|
117
|
-
export {
|
|
118
|
-
|
|
119
|
-
validatePassEntryName,
|
|
120
|
-
} from "./control-plane/secret-backend.js";
|
|
121
|
-
export type { SecretBackend } from "./control-plane/secret-backend.js";
|
|
116
|
+
export { migrateAuth0110 } from "./control-plane/migrate-0110.js";
|
|
117
|
+
export type { MigrateAuth0110Result } from "./control-plane/migrate-0110.js";
|
|
122
118
|
// ── Setup Status ────────────────────────────────────────────────────────
|
|
123
119
|
export {
|
|
124
120
|
isSetupComplete,
|
|
@@ -147,6 +143,7 @@ export {
|
|
|
147
143
|
ensureOpenCodeSystemConfig,
|
|
148
144
|
refreshCoreAssets,
|
|
149
145
|
seedStashAssets,
|
|
146
|
+
seedAssistantPersonaFiles,
|
|
150
147
|
} from "./control-plane/core-assets.js";
|
|
151
148
|
|
|
152
149
|
// ── Configuration Persistence ────────────────────────────────────────────
|
|
@@ -184,6 +181,7 @@ export {
|
|
|
184
181
|
applyUninstall,
|
|
185
182
|
applyUpgrade,
|
|
186
183
|
performUpgrade,
|
|
184
|
+
applyTagChange,
|
|
187
185
|
updateStackEnvToLatestImageTag,
|
|
188
186
|
buildComposeFileList,
|
|
189
187
|
buildManagedServices,
|
|
@@ -234,6 +232,13 @@ export {
|
|
|
234
232
|
buildComposeCliArgs,
|
|
235
233
|
} from "./control-plane/compose-args.js";
|
|
236
234
|
|
|
235
|
+
// ── Compose Error Parsing ────────────────────────────────────────────────
|
|
236
|
+
export type { ComposeServiceFailure } from "./control-plane/compose-errors.js";
|
|
237
|
+
export {
|
|
238
|
+
parseComposeStderr,
|
|
239
|
+
summarizeComposeStderr,
|
|
240
|
+
} from "./control-plane/compose-errors.js";
|
|
241
|
+
|
|
237
242
|
// ── Stack Spec (v2) ──────────────────────────────────────────────────────
|
|
238
243
|
export type {
|
|
239
244
|
StackSpec,
|
|
@@ -250,6 +255,13 @@ export {
|
|
|
250
255
|
writeVoiceVars,
|
|
251
256
|
} from "./control-plane/spec-to-env.js";
|
|
252
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
|
+
|
|
253
265
|
// ── Setup ────────────────────────────────────────────────────────────────
|
|
254
266
|
export type {
|
|
255
267
|
SetupSpec,
|
|
@@ -297,4 +309,5 @@ export {
|
|
|
297
309
|
resolveUiBuildDir,
|
|
298
310
|
seedUiBuild,
|
|
299
311
|
checkAndUpdateUiBuild,
|
|
312
|
+
readCurrentUiBuildVersion,
|
|
300
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
|
|
package/src/logger.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
|
13
13
|
* with un-anchored alternations was sloppy enough to invite future bugs).
|
|
14
14
|
*
|
|
15
15
|
* Examples:
|
|
16
|
-
*
|
|
16
|
+
* OP_UI_LOGIN_PASSWORD → sensitive (suffix _PASSWORD)
|
|
17
17
|
* CHANNEL_API_KEY → sensitive (suffix _KEY)
|
|
18
18
|
* CHANNEL_FOO_HMAC → sensitive (suffix _HMAC)
|
|
19
19
|
* HMAC_KEY → sensitive (prefix HMAC_, suffix _KEY)
|
|
@@ -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,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Audit logging for the OpenPalm control plane.
|
|
3
|
-
*/
|
|
4
|
-
import { mkdirSync, appendFileSync } from "node:fs";
|
|
5
|
-
import type { ControlPlaneState, AuditEntry, CallerType } from "./types.js";
|
|
6
|
-
|
|
7
|
-
const MAX_AUDIT_MEMORY = 1000;
|
|
8
|
-
|
|
9
|
-
export function appendAudit(
|
|
10
|
-
state: ControlPlaneState,
|
|
11
|
-
actor: string,
|
|
12
|
-
action: string,
|
|
13
|
-
args: Record<string, unknown>,
|
|
14
|
-
ok: boolean,
|
|
15
|
-
requestId = "",
|
|
16
|
-
callerType: CallerType = "unknown"
|
|
17
|
-
): void {
|
|
18
|
-
const entry: AuditEntry = {
|
|
19
|
-
at: new Date().toISOString(),
|
|
20
|
-
requestId,
|
|
21
|
-
actor,
|
|
22
|
-
callerType,
|
|
23
|
-
action,
|
|
24
|
-
args,
|
|
25
|
-
ok
|
|
26
|
-
};
|
|
27
|
-
state.audit.push(entry);
|
|
28
|
-
if (state.audit.length > MAX_AUDIT_MEMORY) {
|
|
29
|
-
state.audit = state.audit.slice(-MAX_AUDIT_MEMORY);
|
|
30
|
-
}
|
|
31
|
-
try {
|
|
32
|
-
const logsDir = `${state.stateDir}/logs`;
|
|
33
|
-
mkdirSync(logsDir, { recursive: true });
|
|
34
|
-
appendFileSync(
|
|
35
|
-
`${logsDir}/admin-audit.jsonl`,
|
|
36
|
-
JSON.stringify(entry) + "\n"
|
|
37
|
-
);
|
|
38
|
-
} catch {
|
|
39
|
-
// best-effort persistence
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -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
|
-
});
|
|
@@ -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
|
-
}
|