borgmcp 5.4.1 → 5.6.0
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 +15 -0
- package/dist/assimilate-cmd.d.ts +8 -1
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +54 -21
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +30 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +45 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/docs-sections.d.ts.map +1 -1
- package/dist/docs-sections.js +8 -0
- package/dist/docs-sections.js.map +1 -1
- package/dist/local-server-cursor.d.ts +10 -1
- package/dist/local-server-cursor.d.ts.map +1 -1
- package/dist/local-server-cursor.js +64 -5
- package/dist/local-server-cursor.js.map +1 -1
- package/dist/log-stream.d.ts +13 -0
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +45 -13
- package/dist/log-stream.js.map +1 -1
- package/dist/remote-client.d.ts +17 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +37 -15
- package/dist/remote-client.js.map +1 -1
- package/dist/representative-cmd.d.ts +94 -0
- package/dist/representative-cmd.d.ts.map +1 -0
- package/dist/representative-cmd.js +290 -0
- package/dist/representative-cmd.js.map +1 -0
- package/dist/representative-core.d.ts +243 -0
- package/dist/representative-core.d.ts.map +1 -0
- package/dist/representative-core.js +679 -0
- package/dist/representative-core.js.map +1 -0
- package/dist/representative-delivery-store.d.ts +79 -0
- package/dist/representative-delivery-store.d.ts.map +1 -0
- package/dist/representative-delivery-store.js +233 -0
- package/dist/representative-delivery-store.js.map +1 -0
- package/dist/representative-listener-store.d.ts +46 -0
- package/dist/representative-listener-store.d.ts.map +1 -0
- package/dist/representative-listener-store.js +119 -0
- package/dist/representative-listener-store.js.map +1 -0
- package/dist/representative-listener.d.ts +32 -0
- package/dist/representative-listener.d.ts.map +1 -0
- package/dist/representative-listener.js +293 -0
- package/dist/representative-listener.js.map +1 -0
- package/dist/representative-mcp.d.ts +37 -0
- package/dist/representative-mcp.d.ts.map +1 -0
- package/dist/representative-mcp.js +211 -0
- package/dist/representative-mcp.js.map +1 -0
- package/dist/representative-owner.d.ts +10 -0
- package/dist/representative-owner.d.ts.map +1 -0
- package/dist/representative-owner.js +107 -0
- package/dist/representative-owner.js.map +1 -0
- package/dist/representative-store.d.ts +68 -0
- package/dist/representative-store.d.ts.map +1 -0
- package/dist/representative-store.js +173 -0
- package/dist/representative-store.js.map +1 -0
- package/dist/seat-probe.d.ts +1 -0
- package/dist/seat-probe.d.ts.map +1 -1
- package/dist/seat-probe.js +1 -1
- package/dist/seat-probe.js.map +1 -1
- package/dist/seat-store.d.ts +13 -0
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +55 -10
- package/dist/seat-store.js.map +1 -1
- package/dist/server-trust.d.ts +10 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +23 -6
- package/dist/server-trust.js.map +1 -1
- package/dist/stream-owner.d.ts +10 -0
- package/dist/stream-owner.d.ts.map +1 -1
- package/dist/stream-owner.js +129 -21
- package/dist/stream-owner.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/HUMAN_REPRESENTATIVE.md +434 -0
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +73 -22
- package/src/claude.ts +30 -0
- package/src/cli-help.ts +48 -0
- package/src/docs-sections.ts +8 -0
- package/src/local-server-cursor.ts +56 -4
- package/src/log-stream.ts +47 -14
- package/src/remote-client.ts +54 -13
- package/src/representative-cmd.ts +369 -0
- package/src/representative-core.ts +908 -0
- package/src/representative-delivery-store.ts +235 -0
- package/src/representative-listener-store.ts +115 -0
- package/src/representative-listener.ts +215 -0
- package/src/representative-mcp.ts +250 -0
- package/src/representative-owner.ts +105 -0
- package/src/representative-store.ts +224 -0
- package/src/seat-probe.ts +1 -1
- package/src/seat-store.ts +61 -10
- package/src/server-trust.ts +25 -6
- package/src/stream-owner.ts +129 -20
- package/src/unknown-subcommand.ts +1 -0
package/src/seat-store.ts
CHANGED
|
@@ -49,16 +49,24 @@ interface LockPayload {
|
|
|
49
49
|
export interface SecureStoreOptions {
|
|
50
50
|
secureRoot?: string;
|
|
51
51
|
rootMode?: 'private' | 'owner-controlled';
|
|
52
|
+
/** Harden leaf publication for private lease records; default store behavior is unchanged. */
|
|
53
|
+
verifyLeafIdentity?: boolean;
|
|
54
|
+
/** Publish without replacing an existing leaf (lease takeover marker). */
|
|
55
|
+
exclusive?: boolean;
|
|
56
|
+
/** Read-only lease inspection must not recreate a directory moved by its owner. */
|
|
57
|
+
createRoot?: boolean;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
function expectedUid(): number | null {
|
|
55
61
|
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
/** Shared directory policy; create=false validates without mutating and returns false if absent. */
|
|
65
|
+
export async function assertSecureRoot(
|
|
59
66
|
root: string,
|
|
60
67
|
rootMode: SecureStoreOptions['rootMode'] = 'private',
|
|
61
|
-
|
|
68
|
+
create = true,
|
|
69
|
+
): Promise<boolean> {
|
|
62
70
|
if (!isCanonicalPath(root)) {
|
|
63
71
|
throw new Error(`Borg credential store path ${root} is not canonical`);
|
|
64
72
|
}
|
|
@@ -67,6 +75,7 @@ async function assertSecureRoot(
|
|
|
67
75
|
metadata = await lstat(root);
|
|
68
76
|
} catch (error) {
|
|
69
77
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
78
|
+
if (!create) return false;
|
|
70
79
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
71
80
|
metadata = await lstat(root);
|
|
72
81
|
}
|
|
@@ -87,6 +96,7 @@ async function assertSecureRoot(
|
|
|
87
96
|
if (!isCanonicalPath(root)) {
|
|
88
97
|
throw new Error(`Borg credential store root ${root} is not canonical or contains a symlink`);
|
|
89
98
|
}
|
|
99
|
+
return true;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
async function assertSecureFile(filePath: string): Promise<Awaited<ReturnType<typeof lstat>> | null> {
|
|
@@ -115,7 +125,9 @@ async function assertSecurePath(filePath: string, options: SecureStoreOptions):
|
|
|
115
125
|
if (!isAbsolute(filePath) || resolve(filePath) !== filePath || dirname(filePath) !== secureRoot) {
|
|
116
126
|
throw new Error(`Borg credential store file path ${filePath} is not canonical`);
|
|
117
127
|
}
|
|
118
|
-
await assertSecureRoot(secureRoot, options.rootMode)
|
|
128
|
+
if (!await assertSecureRoot(secureRoot, options.rootMode, options.createRoot !== false)) {
|
|
129
|
+
throw Object.assign(new Error('Borg private store directory disappeared'), { code: 'ENOENT' });
|
|
130
|
+
}
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
/**
|
|
@@ -223,13 +235,28 @@ export async function atomicWrite0600(
|
|
|
223
235
|
else await ensureParentDir(filePath);
|
|
224
236
|
if (options.secureRoot) await assertSecureFile(filePath);
|
|
225
237
|
const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
226
|
-
const handle = await open(tmp,
|
|
238
|
+
const handle = await open(tmp, options.verifyLeafIdentity
|
|
239
|
+
? constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW
|
|
240
|
+
: 'wx', 0o600);
|
|
241
|
+
let opened: Awaited<ReturnType<typeof handle.stat>> | null = null;
|
|
242
|
+
const cleanup = async () => {
|
|
243
|
+
if (options.verifyLeafIdentity) {
|
|
244
|
+
const current = await lstat(tmp).catch(() => null);
|
|
245
|
+
if (!opened || !current || current.dev !== opened.dev || current.ino !== opened.ino) return;
|
|
246
|
+
}
|
|
247
|
+
await unlink(tmp).catch(() => {});
|
|
248
|
+
};
|
|
227
249
|
try {
|
|
250
|
+
opened = options.verifyLeafIdentity ? await handle.stat() : null;
|
|
251
|
+
if (opened && (!opened.isFile() || (opened.mode & 0o777) !== 0o600 ||
|
|
252
|
+
(expectedUid() !== null && opened.uid !== expectedUid()))) {
|
|
253
|
+
throw new Error('Borg private store temporary file is not private');
|
|
254
|
+
}
|
|
228
255
|
await handle.writeFile(data);
|
|
229
256
|
await handle.sync();
|
|
230
257
|
} catch (err) {
|
|
231
258
|
await handle.close().catch(() => {});
|
|
232
|
-
await
|
|
259
|
+
await cleanup();
|
|
233
260
|
throw err;
|
|
234
261
|
}
|
|
235
262
|
await handle.close();
|
|
@@ -238,7 +265,17 @@ export async function atomicWrite0600(
|
|
|
238
265
|
await assertSecurePath(filePath, options);
|
|
239
266
|
await assertSecureFile(filePath);
|
|
240
267
|
}
|
|
241
|
-
|
|
268
|
+
if (opened) {
|
|
269
|
+
const current = await lstat(tmp);
|
|
270
|
+
if (!current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino ||
|
|
271
|
+
current.uid !== opened.uid || (current.mode & 0o777) !== 0o600) {
|
|
272
|
+
throw new Error('Borg private store temporary file identity changed');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (options.exclusive) {
|
|
276
|
+
await link(tmp, filePath);
|
|
277
|
+
await unlink(tmp);
|
|
278
|
+
} else await rename(tmp, filePath);
|
|
242
279
|
const parent = await open(dirname(filePath), 'r');
|
|
243
280
|
try {
|
|
244
281
|
await parent.sync();
|
|
@@ -246,7 +283,7 @@ export async function atomicWrite0600(
|
|
|
246
283
|
await parent.close();
|
|
247
284
|
}
|
|
248
285
|
} catch (err) {
|
|
249
|
-
await
|
|
286
|
+
await cleanup();
|
|
250
287
|
throw err;
|
|
251
288
|
}
|
|
252
289
|
}
|
|
@@ -283,6 +320,20 @@ async function assertSecureStorePerms(
|
|
|
283
320
|
}
|
|
284
321
|
}
|
|
285
322
|
|
|
323
|
+
/** Identity drift is retryable only when inspecting an acquisition lock. */
|
|
324
|
+
export class StoreFileIdentityChangedError extends Error {
|
|
325
|
+
readonly code = 'STORE_FILE_IDENTITY_CHANGED';
|
|
326
|
+
|
|
327
|
+
constructor() {
|
|
328
|
+
super('Borg credential store file changed while it was being opened');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isLockTurnover(error: unknown): boolean {
|
|
333
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
334
|
+
return code === 'ENOENT' || code === 'STORE_FILE_IDENTITY_CHANGED';
|
|
335
|
+
}
|
|
336
|
+
|
|
286
337
|
/**
|
|
287
338
|
* Read the store file, or null when it does not exist (ONLY the missing-file
|
|
288
339
|
* no-op path initializes empty). When the file exists, the 0600-store + 0700-parent
|
|
@@ -310,7 +361,7 @@ export async function readStoreFile(
|
|
|
310
361
|
try {
|
|
311
362
|
const opened = await handle.stat();
|
|
312
363
|
if (opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
313
|
-
throw new
|
|
364
|
+
throw new StoreFileIdentityChangedError();
|
|
314
365
|
}
|
|
315
366
|
const raw = await handle.readFile('utf8');
|
|
316
367
|
const after = await handle.stat();
|
|
@@ -399,7 +450,7 @@ export async function withStoreLock<T>(
|
|
|
399
450
|
if (stored === null) continue;
|
|
400
451
|
raw = stored;
|
|
401
452
|
} catch (readErr) {
|
|
402
|
-
if ((readErr
|
|
453
|
+
if (isLockTurnover(readErr)) continue; // released or replaced — retry
|
|
403
454
|
throw readErr;
|
|
404
455
|
}
|
|
405
456
|
const held = parseLockPayload(raw);
|
|
@@ -422,7 +473,7 @@ export async function withStoreLock<T>(
|
|
|
422
473
|
: {},
|
|
423
474
|
);
|
|
424
475
|
} catch (readErr) {
|
|
425
|
-
if ((readErr
|
|
476
|
+
if (isLockTurnover(readErr)) continue;
|
|
426
477
|
throw readErr;
|
|
427
478
|
}
|
|
428
479
|
if (currentRaw === null || currentRaw !== raw) continue;
|
package/src/server-trust.ts
CHANGED
|
@@ -457,12 +457,7 @@ export async function loadBorgServerTrust(
|
|
|
457
457
|
let pending = trustCache.get(key);
|
|
458
458
|
if (!pending) {
|
|
459
459
|
pending = (async () => {
|
|
460
|
-
const
|
|
461
|
-
readTrustFile(join(directory, 'ca.crt')),
|
|
462
|
-
readTrustFile(join(directory, 'server.json')),
|
|
463
|
-
]);
|
|
464
|
-
const config = decodeTrustConfig(configText);
|
|
465
|
-
const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
|
|
460
|
+
const { certificate, identity } = await readLocalAuthorityTrust(directory);
|
|
466
461
|
return { identity, fetchImpl: createPinnedServerFetch(origin, certificate) };
|
|
467
462
|
})();
|
|
468
463
|
trustCache.set(key, pending);
|
|
@@ -494,6 +489,30 @@ export async function loadBorgServerTrust(
|
|
|
494
489
|
});
|
|
495
490
|
}
|
|
496
491
|
|
|
492
|
+
async function readLocalAuthorityTrust(directory: string): Promise<{ certificate: string; identity: string }> {
|
|
493
|
+
const [certificate, configText] = await Promise.all([
|
|
494
|
+
readTrustFile(join(directory, 'ca.crt')),
|
|
495
|
+
readTrustFile(join(directory, 'server.json')),
|
|
496
|
+
]);
|
|
497
|
+
return { certificate, identity: verifyCaIdentity(certificate, decodeTrustConfig(configText).ca_spki_sha256) };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The current trust identity for `origin`, read from disk on every call with the
|
|
502
|
+
* same source selection and file checks as loadBorgServerTrust. Its local-authority
|
|
503
|
+
* branch caches for the process lifetime, which a continuation guard on a
|
|
504
|
+
* long-lived connection must not rely on; the enrollment branch already rereads
|
|
505
|
+
* its pointer per call. It returns the identity only and grants no trust. The
|
|
506
|
+
* local-authority branch builds no fetch; the enrollment delegation may construct
|
|
507
|
+
* the loader's pinned fetch on a cache miss, but this function sends no request.
|
|
508
|
+
*/
|
|
509
|
+
export async function readBorgServerTrustIdentity(origin: string): Promise<string> {
|
|
510
|
+
if (!await remoteTrustStateExists(origin) && await trustFilesExist(serverDataDirectory())) {
|
|
511
|
+
return (await readLocalAuthorityTrust(serverDataDirectory())).identity;
|
|
512
|
+
}
|
|
513
|
+
return (await loadBorgServerTrust(origin)).identity;
|
|
514
|
+
}
|
|
515
|
+
|
|
497
516
|
function pemCertificate(raw: Buffer): string {
|
|
498
517
|
const body = raw.toString('base64').match(/.{1,64}/g)?.join('\n') ?? '';
|
|
499
518
|
return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`;
|
package/src/stream-owner.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { borgConfigRoot } from './private-root.js';
|
|
5
|
+
import { assertSecureRoot, atomicWrite0600, readStoreFile } from './seat-store.js';
|
|
5
6
|
|
|
6
7
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
8
|
const STREAM_LOCKS_DIR = path.join(borgConfigRoot(), 'stream-locks');
|
|
@@ -58,6 +59,13 @@ export interface StreamOwnerDeps {
|
|
|
58
59
|
pid?: number;
|
|
59
60
|
cwd?: string;
|
|
60
61
|
locksDir?: string;
|
|
62
|
+
/** Production opt-in for private lease storage beneath this canonical root.
|
|
63
|
+
* Ancestors from boundary to root use the store owner-controlled policy;
|
|
64
|
+
* root and descendants require 0700. Uses private reads and exclusive,
|
|
65
|
+
* identity-checked leaf publication. Absent: ordinary stream I/O unchanged.
|
|
66
|
+
* Static hostile objects are in scope; same-UID syscall races are not.
|
|
67
|
+
*/
|
|
68
|
+
privateRoot?: { root: string; boundary: string };
|
|
61
69
|
processNonce?: string;
|
|
62
70
|
processStartedAt?: string;
|
|
63
71
|
worktree?: string;
|
|
@@ -88,7 +96,8 @@ export async function acquireStreamLease(
|
|
|
88
96
|
deps: StreamOwnerDeps = {}
|
|
89
97
|
): Promise<StreamLease | null> {
|
|
90
98
|
const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
|
|
91
|
-
await
|
|
99
|
+
if (deps.privateRoot) await privateLeaseDirectory(path.dirname(lockPath), deps, true);
|
|
100
|
+
else await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
92
101
|
|
|
93
102
|
if (!(await recoverTakeoverClaim(lockPath, staleMs, deps))) return null;
|
|
94
103
|
|
|
@@ -130,8 +139,37 @@ export async function readOwnershipSnapshot(
|
|
|
130
139
|
deps: StreamOwnerDeps = {}
|
|
131
140
|
): Promise<StreamOwnershipSnapshot> {
|
|
132
141
|
const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
// A refresh renames the lock to `<lock>.takeover` while it rewrites the record
|
|
143
|
+
// and then restores it. Read-only: report an intact, fresh, live in-flight
|
|
144
|
+
// owner there; a stale, future-dated, dead or malformed leftover stays
|
|
145
|
+
// `unowned`. A missing directory or owner leaf is re-resolved from the lock
|
|
146
|
+
// path, so one refresh overlapping any step cannot hide the owner; genuine
|
|
147
|
+
// initialization still reports `initializing` after the bounded attempts.
|
|
148
|
+
let initializing: StreamOwnershipSnapshot | undefined;
|
|
149
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
150
|
+
const inspected = await readBoundOwner(lockPath, deps);
|
|
151
|
+
if (inspected) {
|
|
152
|
+
const snapshot = snapshotFromOwner(lockPath, inspected, deps);
|
|
153
|
+
if (inspected.raw !== null) return snapshot;
|
|
154
|
+
initializing = snapshot;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const claimed = await readBoundOwner(takeoverPath(lockPath), deps);
|
|
158
|
+
if (!claimed || claimed.raw === null) continue;
|
|
159
|
+
const snapshot = snapshotFromOwner(lockPath, claimed, deps);
|
|
160
|
+
const ageMs = snapshot.ageMs ?? Infinity;
|
|
161
|
+
const live = snapshot.pid !== undefined && ageMs >= 0 && ageMs <= STREAM_OWNER_STALE_MS &&
|
|
162
|
+
isPidAlive(snapshot.pid, deps);
|
|
163
|
+
return live ? snapshot : { state: 'unowned', lockPath };
|
|
164
|
+
}
|
|
165
|
+
return initializing ?? { state: 'unowned', lockPath };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function snapshotFromOwner(
|
|
169
|
+
lockPath: string,
|
|
170
|
+
inspected: { raw: string | null; stat: { dev: number; ino: number; mtimeMs: number } },
|
|
171
|
+
deps: StreamOwnerDeps,
|
|
172
|
+
): StreamOwnershipSnapshot {
|
|
135
173
|
const { raw, stat: lockStat } = inspected;
|
|
136
174
|
if (raw === null) {
|
|
137
175
|
const now = (deps.now ?? (() => new Date()))();
|
|
@@ -200,6 +238,10 @@ async function tryCreateLease(
|
|
|
200
238
|
lockPath: string,
|
|
201
239
|
deps: StreamOwnerDeps
|
|
202
240
|
): Promise<StreamLease | null> {
|
|
241
|
+
if (deps.privateRoot) {
|
|
242
|
+
await privateLeaseDirectory(lockPath, deps, false);
|
|
243
|
+
await privateLeaseDirectory(takeoverPath(lockPath), deps, false);
|
|
244
|
+
}
|
|
203
245
|
if (await pathExists(takeoverPath(lockPath))) return null;
|
|
204
246
|
try {
|
|
205
247
|
await fs.mkdir(lockPath, { mode: 0o700 });
|
|
@@ -210,22 +252,22 @@ async function tryCreateLease(
|
|
|
210
252
|
|
|
211
253
|
const record = makeRecord(deps);
|
|
212
254
|
try {
|
|
213
|
-
await (
|
|
255
|
+
await writeLeaseRecord(lockPath, record, deps);
|
|
214
256
|
if (await pathExists(takeoverPath(lockPath))) {
|
|
215
|
-
await cleanupFailedInitialization(lockPath, record).catch(() => {});
|
|
257
|
+
await cleanupFailedInitialization(lockPath, record, deps).catch(() => {});
|
|
216
258
|
return null;
|
|
217
259
|
}
|
|
218
260
|
} catch (err) {
|
|
219
|
-
await cleanupFailedInitialization(lockPath, record).catch(() => {});
|
|
261
|
+
await cleanupFailedInitialization(lockPath, record, deps).catch(() => {});
|
|
220
262
|
throw err;
|
|
221
263
|
}
|
|
222
|
-
const created = await readBoundOwner(lockPath);
|
|
264
|
+
const created = await readBoundOwner(lockPath, deps);
|
|
223
265
|
const createdRecord = created?.raw ? parseOwnershipRecord(created.raw) : null;
|
|
224
266
|
if (
|
|
225
267
|
!created || !createdRecord ||
|
|
226
268
|
!sameOwner(record, createdRecord)
|
|
227
269
|
) {
|
|
228
|
-
await cleanupFailedInitialization(lockPath, record).catch(() => {});
|
|
270
|
+
await cleanupFailedInitialization(lockPath, record, deps).catch(() => {});
|
|
229
271
|
throw new Error('Borg stream lease initialization lost ownership');
|
|
230
272
|
}
|
|
231
273
|
return makeLease(
|
|
@@ -243,7 +285,8 @@ async function tryCreateLease(
|
|
|
243
285
|
*/
|
|
244
286
|
async function cleanupFailedInitialization(
|
|
245
287
|
lockPath: string,
|
|
246
|
-
attempted: StreamOwnerRecord
|
|
288
|
+
attempted: StreamOwnerRecord,
|
|
289
|
+
deps: StreamOwnerDeps,
|
|
247
290
|
): Promise<void> {
|
|
248
291
|
const cleanupPath = `${lockPath}.failed-${attempted.processNonce}-${Date.now()}`;
|
|
249
292
|
try {
|
|
@@ -252,7 +295,7 @@ async function cleanupFailedInitialization(
|
|
|
252
295
|
if (err?.code === 'ENOENT') return;
|
|
253
296
|
throw err;
|
|
254
297
|
}
|
|
255
|
-
const current = await readOwnershipRecord(cleanupPath);
|
|
298
|
+
const current = await readOwnershipRecord(cleanupPath, deps);
|
|
256
299
|
if (current && (current.pid !== attempted.pid || current.processNonce !== attempted.processNonce)) {
|
|
257
300
|
try {
|
|
258
301
|
await fs.rename(cleanupPath, lockPath);
|
|
@@ -274,6 +317,10 @@ async function moveStaleLockAside(
|
|
|
274
317
|
deps: StreamOwnerDeps
|
|
275
318
|
): Promise<boolean> {
|
|
276
319
|
const claimPath = takeoverPath(lockPath);
|
|
320
|
+
if (deps.privateRoot) {
|
|
321
|
+
await privateLeaseDirectory(lockPath, deps, false);
|
|
322
|
+
await privateLeaseDirectory(claimPath, deps, false);
|
|
323
|
+
}
|
|
277
324
|
try {
|
|
278
325
|
await fs.rename(lockPath, claimPath);
|
|
279
326
|
} catch (err: any) {
|
|
@@ -301,13 +348,14 @@ async function moveStaleLockAside(
|
|
|
301
348
|
processNonce: deps.processNonce ?? processNonce,
|
|
302
349
|
claimedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
303
350
|
};
|
|
304
|
-
await
|
|
351
|
+
if (deps.privateRoot) await writePrivateLeaseFile(claimPath, TAKEOVER_FILE, claimant, deps, true);
|
|
352
|
+
else await fs.writeFile(
|
|
305
353
|
path.join(claimPath, TAKEOVER_FILE),
|
|
306
354
|
JSON.stringify(claimant, null, 2) + '\n',
|
|
307
355
|
{ flag: 'wx', mode: 0o600 },
|
|
308
356
|
);
|
|
309
357
|
await deps.beforeTakeoverVerify?.(claimPath);
|
|
310
|
-
const verified = await readOwnershipRecord(claimPath);
|
|
358
|
+
const verified = await readOwnershipRecord(claimPath, deps);
|
|
311
359
|
const verifiedStat = await fs.stat(claimPath).catch(() => null);
|
|
312
360
|
if (!isStillReclaimable(snapshot, verified, verifiedStat, staleMs, deps)) {
|
|
313
361
|
await fs.unlink(path.join(claimPath, TAKEOVER_FILE)).catch(() => {});
|
|
@@ -344,6 +392,10 @@ async function recoverTakeoverClaim(
|
|
|
344
392
|
deps: StreamOwnerDeps,
|
|
345
393
|
): Promise<boolean> {
|
|
346
394
|
const claimPath = takeoverPath(lockPath);
|
|
395
|
+
if (deps.privateRoot) {
|
|
396
|
+
await privateLeaseDirectory(lockPath, deps, false);
|
|
397
|
+
await privateLeaseDirectory(claimPath, deps, false);
|
|
398
|
+
}
|
|
347
399
|
let claimStat;
|
|
348
400
|
try {
|
|
349
401
|
claimStat = await fs.stat(claimPath);
|
|
@@ -353,9 +405,14 @@ async function recoverTakeoverClaim(
|
|
|
353
405
|
}
|
|
354
406
|
|
|
355
407
|
const now = (deps.now ?? (() => new Date()))();
|
|
408
|
+
const privateClaim = deps.privateRoot
|
|
409
|
+
? await readStoreFile(path.join(claimPath, TAKEOVER_FILE), { secureRoot: claimPath, createRoot: false })
|
|
410
|
+
: undefined;
|
|
356
411
|
let claimant: { pid: number; processNonce: string; claimedAt: string } | null = null;
|
|
357
412
|
try {
|
|
358
|
-
const parsed = JSON.parse(
|
|
413
|
+
const parsed = JSON.parse((deps.privateRoot
|
|
414
|
+
? privateClaim
|
|
415
|
+
: await fs.readFile(path.join(claimPath, TAKEOVER_FILE), 'utf8')) ?? 'null');
|
|
359
416
|
if (
|
|
360
417
|
parsed?.schemaVersion === 1 &&
|
|
361
418
|
Number.isSafeInteger(parsed.pid) && parsed.pid > 0 &&
|
|
@@ -377,7 +434,7 @@ async function recoverTakeoverClaim(
|
|
|
377
434
|
) return false;
|
|
378
435
|
if (!claimant && claimAge <= STREAM_OWNER_TAKEOVER_STALE_MS) return false;
|
|
379
436
|
|
|
380
|
-
const owner = await readOwnershipRecord(claimPath);
|
|
437
|
+
const owner = await readOwnershipRecord(claimPath, deps);
|
|
381
438
|
const ownerHeartbeat = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
|
|
382
439
|
const ownerAge = Number.isFinite(ownerHeartbeat)
|
|
383
440
|
? now.getTime() - ownerHeartbeat
|
|
@@ -481,7 +538,7 @@ function makeLease(
|
|
|
481
538
|
ownedRecord,
|
|
482
539
|
deps,
|
|
483
540
|
async (claimPath) => {
|
|
484
|
-
await (
|
|
541
|
+
await writeLeaseRecord(claimPath, next, deps);
|
|
485
542
|
return 'restore';
|
|
486
543
|
},
|
|
487
544
|
);
|
|
@@ -511,6 +568,10 @@ async function mutateClaimedLease(
|
|
|
511
568
|
mutation: (claimPath: string) => Promise<'restore' | 'remove'>,
|
|
512
569
|
): Promise<boolean> {
|
|
513
570
|
const claimPath = takeoverPath(lockPath);
|
|
571
|
+
if (deps.privateRoot) {
|
|
572
|
+
await privateLeaseDirectory(lockPath, deps, false);
|
|
573
|
+
await privateLeaseDirectory(claimPath, deps, false);
|
|
574
|
+
}
|
|
514
575
|
try {
|
|
515
576
|
await fs.rename(lockPath, claimPath);
|
|
516
577
|
} catch (error: any) {
|
|
@@ -532,13 +593,14 @@ async function mutateClaimedLease(
|
|
|
532
593
|
processNonce: deps.processNonce ?? processNonce,
|
|
533
594
|
claimedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
534
595
|
};
|
|
535
|
-
await
|
|
596
|
+
if (deps.privateRoot) await writePrivateLeaseFile(claimPath, TAKEOVER_FILE, claimant, deps, true);
|
|
597
|
+
else await fs.writeFile(
|
|
536
598
|
path.join(claimPath, TAKEOVER_FILE),
|
|
537
599
|
JSON.stringify(claimant, null, 2) + '\n',
|
|
538
600
|
{ flag: 'wx', mode: 0o600 },
|
|
539
601
|
);
|
|
540
602
|
|
|
541
|
-
const inspected = await readBoundOwner(claimPath);
|
|
603
|
+
const inspected = await readBoundOwner(claimPath, deps);
|
|
542
604
|
const current = inspected?.raw ? parseOwnershipRecord(inspected.raw) : null;
|
|
543
605
|
if (
|
|
544
606
|
!inspected || !sameIdentity(identity, inspected.stat) || !current ||
|
|
@@ -590,7 +652,11 @@ function sameOwner(left: StreamOwnerRecord, right: StreamOwnerRecord): boolean {
|
|
|
590
652
|
left.cubeName === right.cubeName;
|
|
591
653
|
}
|
|
592
654
|
|
|
593
|
-
async function readOwnershipRecord(lockPath: string): Promise<StreamOwnerRecord | null> {
|
|
655
|
+
async function readOwnershipRecord(lockPath: string, deps: StreamOwnerDeps = {}): Promise<StreamOwnerRecord | null> {
|
|
656
|
+
if (deps.privateRoot) {
|
|
657
|
+
const inspected = await readBoundOwner(lockPath, deps);
|
|
658
|
+
return inspected?.raw ? parseOwnershipRecord(inspected.raw) : null;
|
|
659
|
+
}
|
|
594
660
|
try {
|
|
595
661
|
const raw = await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
|
|
596
662
|
return parseOwnershipRecord(raw);
|
|
@@ -608,10 +674,11 @@ function parseOwnershipRecord(raw: string): StreamOwnerRecord | null {
|
|
|
608
674
|
}
|
|
609
675
|
}
|
|
610
676
|
|
|
611
|
-
async function readBoundOwner(lockPath: string): Promise<{
|
|
677
|
+
async function readBoundOwner(lockPath: string, deps: StreamOwnerDeps = {}): Promise<{
|
|
612
678
|
raw: string | null;
|
|
613
679
|
stat: { dev: number; ino: number; mtimeMs: number };
|
|
614
680
|
} | null> {
|
|
681
|
+
if (deps.privateRoot && !await privateLeaseDirectory(lockPath, deps, false)) return null;
|
|
615
682
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
616
683
|
let handle;
|
|
617
684
|
try {
|
|
@@ -624,8 +691,13 @@ async function readBoundOwner(lockPath: string): Promise<{
|
|
|
624
691
|
const openedStat = await handle.stat();
|
|
625
692
|
let raw: string | null;
|
|
626
693
|
try {
|
|
627
|
-
raw =
|
|
694
|
+
raw = deps.privateRoot
|
|
695
|
+
? await readStoreFile(path.join(lockPath, OWNER_FILE), { secureRoot: lockPath, createRoot: false })
|
|
696
|
+
: await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
|
|
628
697
|
} catch (error: any) {
|
|
698
|
+
// A concurrent refresh atomically replaces the owner file; like other
|
|
699
|
+
// lock inspection, that identity drift is turnover and is re-read.
|
|
700
|
+
if (error?.code === 'STORE_FILE_IDENTITY_CHANGED') continue;
|
|
629
701
|
if (error?.code !== 'ENOENT') throw error;
|
|
630
702
|
raw = null;
|
|
631
703
|
}
|
|
@@ -652,6 +724,43 @@ function sameIdentity(
|
|
|
652
724
|
return left.dev === right.dev && left.ino === right.ino;
|
|
653
725
|
}
|
|
654
726
|
|
|
727
|
+
/** Apply the existing private-store directory policy without creating on reads. */
|
|
728
|
+
async function privateLeaseDirectory(directory: string, deps: StreamOwnerDeps, create: boolean): Promise<boolean> {
|
|
729
|
+
const { root, boundary } = deps.privateRoot!;
|
|
730
|
+
const ancestors = path.relative(boundary, root);
|
|
731
|
+
if (ancestors.startsWith('..') || path.isAbsolute(ancestors)) {
|
|
732
|
+
throw new Error('Borg private lease root escapes its trusted boundary');
|
|
733
|
+
}
|
|
734
|
+
const relative = path.relative(root, directory);
|
|
735
|
+
if (path.resolve(directory) !== directory || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
736
|
+
throw new Error('Borg private lease path escapes its private root');
|
|
737
|
+
}
|
|
738
|
+
let ancestor = boundary;
|
|
739
|
+
for (const component of ancestors.split(path.sep).filter(Boolean)) {
|
|
740
|
+
if (!await assertSecureRoot(ancestor, 'owner-controlled', create)) return false;
|
|
741
|
+
ancestor = path.join(ancestor, component);
|
|
742
|
+
}
|
|
743
|
+
if (!await assertSecureRoot(root, 'private', create)) return false;
|
|
744
|
+
let current = root;
|
|
745
|
+
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
746
|
+
current = path.join(current, component);
|
|
747
|
+
if (!await assertSecureRoot(current, 'private', create)) return false;
|
|
748
|
+
}
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function writePrivateLeaseFile(directory: string, name: string, value: unknown, deps: StreamOwnerDeps, exclusive = false): Promise<void> {
|
|
753
|
+
if (!await privateLeaseDirectory(directory, deps, false)) throw new Error('Borg private lease directory disappeared');
|
|
754
|
+
await atomicWrite0600(path.join(directory, name), JSON.stringify(value, null, 2) + '\n', {
|
|
755
|
+
secureRoot: directory, verifyLeafIdentity: true, exclusive, createRoot: false,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function writeLeaseRecord(directory: string, record: StreamOwnerRecord, deps: StreamOwnerDeps): Promise<void> {
|
|
760
|
+
if (deps.privateRoot) await writePrivateLeaseFile(directory, OWNER_FILE, record, deps);
|
|
761
|
+
else await (deps.writeRecord ?? writeRecord)(directory, record);
|
|
762
|
+
}
|
|
763
|
+
|
|
655
764
|
async function writeRecord(lockPath: string, record: StreamOwnerRecord): Promise<void> {
|
|
656
765
|
const ownerPath = path.join(lockPath, OWNER_FILE);
|
|
657
766
|
const tmpPath = path.join(lockPath, `${OWNER_FILE}.${record.processNonce}.tmp`);
|