borgmcp-server 0.1.8 → 0.1.9
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 +57 -43
- package/SECURITY.md +12 -1
- package/dist/bootstrap.d.ts +8 -1
- package/dist/bootstrap.js +39 -6
- package/dist/bootstrap.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +176 -1
- package/dist/cli.js.map +1 -1
- package/dist/credentials.d.ts +1 -0
- package/dist/credentials.js +7 -0
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +1 -1
- package/dist/debug-log.js +1 -1
- package/dist/debug-log.js.map +1 -1
- package/dist/https-server.d.ts +3 -0
- package/dist/https-server.js +25 -7
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +1 -0
- package/dist/main.js.map +1 -1
- package/dist/managed-service.d.ts +20 -0
- package/dist/managed-service.js +80 -0
- package/dist/managed-service.js.map +1 -0
- package/dist/portable-credential-store.d.ts +16 -0
- package/dist/portable-credential-store.js +305 -0
- package/dist/portable-credential-store.js.map +1 -0
- package/dist/registry-artifact.d.ts +11 -0
- package/dist/registry-artifact.js +99 -0
- package/dist/registry-artifact.js.map +1 -0
- package/dist/runtime-identity.d.ts +19 -0
- package/dist/runtime-identity.js +61 -0
- package/dist/runtime-identity.js.map +1 -0
- package/dist/runtime-lifecycle.d.ts +56 -0
- package/dist/runtime-lifecycle.js +486 -0
- package/dist/runtime-lifecycle.js.map +1 -0
- package/dist/runtime-operator.d.ts +24 -0
- package/dist/runtime-operator.js +95 -0
- package/dist/runtime-operator.js.map +1 -0
- package/dist/service.d.ts +53 -5
- package/dist/service.js +326 -15
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +1 -0
- package/dist/store.js +8 -0
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/bootstrap.ts +46 -5
- package/src/cli.ts +171 -1
- package/src/credentials.ts +7 -0
- package/src/debug-log.ts +2 -1
- package/src/https-server.ts +27 -7
- package/src/index.ts +46 -1
- package/src/main.ts +1 -0
- package/src/managed-service.ts +107 -0
- package/src/portable-credential-store.ts +306 -0
- package/src/registry-artifact.ts +111 -0
- package/src/runtime-identity.ts +82 -0
- package/src/runtime-lifecycle.ts +567 -0
- package/src/runtime-operator.ts +124 -0
- package/src/service.ts +401 -20
- package/src/store.ts +10 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
|
|
7
|
+
export interface PortableServerCredential {
|
|
8
|
+
readonly version: 2;
|
|
9
|
+
readonly origin: string;
|
|
10
|
+
readonly trustIdentity: string;
|
|
11
|
+
readonly credential: string;
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
readonly serverCapabilities: readonly ["create_cube"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PortableCredentialDocument {
|
|
17
|
+
readonly version: 1;
|
|
18
|
+
readonly accounts: Readonly<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const credentialPattern = /^[A-Za-z0-9_-]{43}$/u;
|
|
22
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
23
|
+
const trustPattern = /^spki-sha256:[0-9a-f]{64}$/u;
|
|
24
|
+
const defaultLockAttempts = 500;
|
|
25
|
+
const defaultLockWaitMs = 10;
|
|
26
|
+
|
|
27
|
+
export interface PortableCredentialLockOptions {
|
|
28
|
+
readonly attempts?: number;
|
|
29
|
+
readonly waitMs?: number;
|
|
30
|
+
readonly onAcquired?: (lockPath: string) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function portableCredentialAccount(origin: string, trustIdentity: string): string {
|
|
34
|
+
return `borg-server-credential:${createHash("sha256").update(origin).update("\0").update(trustIdentity).digest("hex")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function writePortableServerCredential(
|
|
38
|
+
path: string,
|
|
39
|
+
record: PortableServerCredential,
|
|
40
|
+
lockOptions: PortableCredentialLockOptions = {},
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
validateRecord(record);
|
|
43
|
+
const target = await credentialPath(path);
|
|
44
|
+
const canonicalRoot = dirname(target);
|
|
45
|
+
const lock = `${target}.lock`;
|
|
46
|
+
await withCredentialLock(lock, lockOptions, async () => {
|
|
47
|
+
const original = await readPrivateBytesIfPresent(target);
|
|
48
|
+
const document = parseDocument(original);
|
|
49
|
+
const account = portableCredentialAccount(record.origin, record.trustIdentity);
|
|
50
|
+
const next: PortableCredentialDocument = {
|
|
51
|
+
version: 1,
|
|
52
|
+
accounts: { ...document.accounts, [account]: JSON.stringify(record) },
|
|
53
|
+
};
|
|
54
|
+
const temporary = join(canonicalRoot, `.credentials.json.${process.pid}.${Date.now()}.tmp`);
|
|
55
|
+
const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
56
|
+
try {
|
|
57
|
+
await handle.writeFile(`${JSON.stringify(next)}\n`, "utf8");
|
|
58
|
+
await handle.sync();
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await assertPrivateFile(temporary);
|
|
64
|
+
const current = await readPrivateBytesIfPresent(target);
|
|
65
|
+
if (current === null ? original !== null : original === null || !current.equals(original)) {
|
|
66
|
+
throw new Error("Portable credential store changed during update.");
|
|
67
|
+
}
|
|
68
|
+
await rename(temporary, target);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await unlink(temporary).catch(() => undefined);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const directory = await open(canonicalRoot, constants.O_RDONLY);
|
|
74
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
75
|
+
await assertPrivateFile(target);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function readPortableServerCredential(
|
|
80
|
+
path: string,
|
|
81
|
+
origin: string,
|
|
82
|
+
trustIdentity: string,
|
|
83
|
+
): Promise<PortableServerCredential> {
|
|
84
|
+
const target = await credentialPath(path);
|
|
85
|
+
await assertPrivateFile(target);
|
|
86
|
+
const document = parseDocument(await readPrivateBytes(target));
|
|
87
|
+
const value = document.accounts[portableCredentialAccount(origin, trustIdentity)];
|
|
88
|
+
if (value === undefined) throw new Error("Local owner credential is unavailable.");
|
|
89
|
+
const parsed: unknown = JSON.parse(value);
|
|
90
|
+
validateRecord(parsed);
|
|
91
|
+
if (parsed.origin !== origin || parsed.trustIdentity !== trustIdentity) {
|
|
92
|
+
throw new Error("Local owner credential binding is invalid.");
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(parsed);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function credentialPath(path: string): Promise<string> {
|
|
98
|
+
if (path !== resolve(path)) throw new Error("Portable credential path is unsafe.");
|
|
99
|
+
const target = path;
|
|
100
|
+
const parent = dirname(target);
|
|
101
|
+
let parentMetadata;
|
|
102
|
+
try {
|
|
103
|
+
parentMetadata = await lstat(parent);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
106
|
+
await mkdir(parent, { mode: 0o700 }).catch((mkdirError: unknown) => {
|
|
107
|
+
if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError;
|
|
108
|
+
});
|
|
109
|
+
parentMetadata = await lstat(parent);
|
|
110
|
+
}
|
|
111
|
+
if (!parentMetadata.isDirectory() || parentMetadata.isSymbolicLink() ||
|
|
112
|
+
(parentMetadata.mode & 0o022) !== 0 ||
|
|
113
|
+
(typeof process.getuid === "function" && parentMetadata.uid !== process.getuid()) ||
|
|
114
|
+
await realpath(parent) !== parent) {
|
|
115
|
+
throw new Error("Portable credential parent directory is unsafe.");
|
|
116
|
+
}
|
|
117
|
+
return target;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function withCredentialLock<T>(
|
|
121
|
+
lockPath: string,
|
|
122
|
+
options: PortableCredentialLockOptions,
|
|
123
|
+
operation: () => Promise<T>,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
const attempts = options.attempts ?? defaultLockAttempts;
|
|
126
|
+
const waitMs = options.waitMs ?? defaultLockWaitMs;
|
|
127
|
+
if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 10_000 ||
|
|
128
|
+
!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 1_000) {
|
|
129
|
+
throw new Error("Portable credential lock options are invalid.");
|
|
130
|
+
}
|
|
131
|
+
await credentialPath(lockPath.slice(0, -".lock".length));
|
|
132
|
+
const stage = `${lockPath}.${process.pid}.${randomBytes(6).toString("hex")}.acq`;
|
|
133
|
+
const payload = Buffer.from(JSON.stringify({
|
|
134
|
+
pid: process.pid,
|
|
135
|
+
startTime: new Date(Date.now() - Math.round(process.uptime() * 1_000)).toISOString(),
|
|
136
|
+
}));
|
|
137
|
+
const stageHandle = await open(stage, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
138
|
+
try {
|
|
139
|
+
try {
|
|
140
|
+
await stageHandle.writeFile(payload);
|
|
141
|
+
await stageHandle.sync();
|
|
142
|
+
} finally {
|
|
143
|
+
await stageHandle.close();
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await unlink(stage).catch(() => undefined);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
let acquired = false;
|
|
150
|
+
try {
|
|
151
|
+
await assertPrivateFile(stage);
|
|
152
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
153
|
+
try {
|
|
154
|
+
await link(stage, lockPath);
|
|
155
|
+
acquired = true;
|
|
156
|
+
break;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
159
|
+
}
|
|
160
|
+
const state = await inspectContendedLock(lockPath);
|
|
161
|
+
if (state === "missing") continue;
|
|
162
|
+
if (state === "stale") throw new Error(`Borg credential lock is stale: ${lockPath}`);
|
|
163
|
+
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
164
|
+
}
|
|
165
|
+
if (!acquired) throw new Error("Borg seat store is busy");
|
|
166
|
+
await options.onAcquired?.(lockPath);
|
|
167
|
+
return await operation();
|
|
168
|
+
} finally {
|
|
169
|
+
let releaseError: unknown;
|
|
170
|
+
if (acquired) {
|
|
171
|
+
try {
|
|
172
|
+
await unlink(lockPath);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") releaseError = error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await unlink(stage).catch(() => undefined);
|
|
178
|
+
if (releaseError !== undefined) throw releaseError;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function inspectContendedLock(lockPath: string): Promise<"live" | "stale" | "missing"> {
|
|
183
|
+
try {
|
|
184
|
+
await credentialPath(lockPath.slice(0, -".lock".length));
|
|
185
|
+
const before = await lstat(lockPath);
|
|
186
|
+
if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600 ||
|
|
187
|
+
(typeof process.getuid === "function" && before.uid !== process.getuid())) {
|
|
188
|
+
throw new Error("Portable credential lock is unsafe.");
|
|
189
|
+
}
|
|
190
|
+
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
191
|
+
let bytes: Buffer;
|
|
192
|
+
try {
|
|
193
|
+
const opened = await handle.stat();
|
|
194
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino ||
|
|
195
|
+
opened.size !== before.size || opened.size > 4_096) {
|
|
196
|
+
throw new Error("Portable credential lock is unsafe.");
|
|
197
|
+
}
|
|
198
|
+
bytes = await handle.readFile();
|
|
199
|
+
const after = await handle.stat();
|
|
200
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
201
|
+
throw new Error("Portable credential lock is unsafe.");
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
await handle.close();
|
|
205
|
+
}
|
|
206
|
+
await credentialPath(lockPath.slice(0, -".lock".length));
|
|
207
|
+
let parsed: unknown;
|
|
208
|
+
try { parsed = JSON.parse(bytes.toString("utf8")); } catch { return "stale"; }
|
|
209
|
+
const pid = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
210
|
+
? (parsed as { pid?: unknown }).pid
|
|
211
|
+
: undefined;
|
|
212
|
+
if (!Number.isSafeInteger(pid) || (pid as number) < 1) return "stale";
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid as number, 0);
|
|
215
|
+
return "live";
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return (error as NodeJS.ErrnoException).code === "EPERM" ? "live" : "stale";
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function assertPrivateFile(path: string): Promise<void> {
|
|
226
|
+
const metadata = await lstat(path);
|
|
227
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o777) !== 0o600 ||
|
|
228
|
+
metadata.nlink !== 1 ||
|
|
229
|
+
(typeof process.getuid === "function" && metadata.uid !== process.getuid())) {
|
|
230
|
+
throw new Error("Portable credential file is unsafe.");
|
|
231
|
+
}
|
|
232
|
+
if ((await realpath(dirname(path))) !== dirname(path) || await realpath(path) !== path) {
|
|
233
|
+
throw new Error("Portable credential file is unsafe.");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readPrivateBytes(path: string): Promise<Buffer> {
|
|
238
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
239
|
+
try {
|
|
240
|
+
const metadata = await handle.stat();
|
|
241
|
+
if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600 || metadata.nlink !== 1 ||
|
|
242
|
+
(typeof process.getuid === "function" && metadata.uid !== process.getuid()) ||
|
|
243
|
+
metadata.size > 1024 * 1024) throw new Error("Portable credential file is unsafe.");
|
|
244
|
+
return await handle.readFile();
|
|
245
|
+
} finally {
|
|
246
|
+
await handle.close();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function readPrivateBytesIfPresent(path: string): Promise<Buffer | null> {
|
|
251
|
+
try {
|
|
252
|
+
await assertPrivateFile(path);
|
|
253
|
+
return await readPrivateBytes(path);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseDocument(bytes: Buffer | null): PortableCredentialDocument {
|
|
261
|
+
if (bytes === null) return { version: 1, accounts: {} };
|
|
262
|
+
const parsed: unknown = JSON.parse(bytes.toString("utf8"));
|
|
263
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ||
|
|
264
|
+
(parsed as { version?: unknown }).version !== 1) throw new Error("Portable credential store is invalid.");
|
|
265
|
+
const accounts = (parsed as { accounts?: unknown }).accounts;
|
|
266
|
+
if (typeof accounts !== "object" || accounts === null || Array.isArray(accounts)) {
|
|
267
|
+
throw new Error("Portable credential store is invalid.");
|
|
268
|
+
}
|
|
269
|
+
if (Object.keys(accounts).length > 1_024) throw new Error("Portable credential store is invalid.");
|
|
270
|
+
for (const [key, value] of Object.entries(accounts)) {
|
|
271
|
+
if (!/^[A-Za-z0-9._:-]{1,256}$/u.test(key) || typeof value !== "string" || value.length > 65_536) {
|
|
272
|
+
throw new Error("Portable credential store is invalid.");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { version: 1, accounts: accounts as Record<string, string> };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateRecord(value: unknown): asserts value is PortableServerCredential {
|
|
279
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Portable credential is invalid.");
|
|
280
|
+
const record = value as Record<string, unknown>;
|
|
281
|
+
if (Object.keys(record).sort().join(",") !== "clientId,credential,origin,serverCapabilities,trustIdentity,version" ||
|
|
282
|
+
record["version"] !== 2 || typeof record["origin"] !== "string" ||
|
|
283
|
+
!isCanonicalHttpsIpOrigin(record["origin"]) ||
|
|
284
|
+
typeof record["trustIdentity"] !== "string" || !trustPattern.test(record["trustIdentity"]) ||
|
|
285
|
+
typeof record["credential"] !== "string" || !credentialPattern.test(record["credential"]) ||
|
|
286
|
+
typeof record["clientId"] !== "string" || !uuidPattern.test(record["clientId"]) ||
|
|
287
|
+
!Array.isArray(record["serverCapabilities"]) || record["serverCapabilities"].length !== 1 ||
|
|
288
|
+
record["serverCapabilities"][0] !== "create_cube") {
|
|
289
|
+
throw new Error("Portable credential is invalid.");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isCanonicalHttpsIpOrigin(value: string): boolean {
|
|
294
|
+
try {
|
|
295
|
+
const url = new URL(value);
|
|
296
|
+
const host = url.hostname.startsWith("[") && url.hostname.endsWith("]")
|
|
297
|
+
? url.hostname.slice(1, -1)
|
|
298
|
+
: url.hostname;
|
|
299
|
+
const port = Number(url.port);
|
|
300
|
+
return url.protocol === "https:" && isIP(host) !== 0 && Number.isInteger(port) && port >= 1 &&
|
|
301
|
+
port <= 65_535 && url.username === "" && url.password === "" && url.pathname === "/" &&
|
|
302
|
+
url.search === "" && url.hash === "" && url.origin === value;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const registryOrigin = "https://registry.npmjs.org";
|
|
5
|
+
const exactVersion = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u;
|
|
6
|
+
const integrityPattern = /^sha512-[A-Za-z0-9+/]{86}==$/u;
|
|
7
|
+
const sourceShaPattern = /^[0-9a-f]{40}$/u;
|
|
8
|
+
|
|
9
|
+
export interface RegistryRuntimeArtifact {
|
|
10
|
+
readonly tarballPath: string;
|
|
11
|
+
readonly version: string;
|
|
12
|
+
readonly integrity: string;
|
|
13
|
+
readonly sourceSha: string | null;
|
|
14
|
+
readonly cleanup: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RegistryArtifactSource {
|
|
18
|
+
readonly latest: (runtimeRoot: string, signal: AbortSignal) => Promise<RegistryRuntimeArtifact>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createRegistryArtifactSource(
|
|
22
|
+
request: typeof fetch = fetch,
|
|
23
|
+
): RegistryArtifactSource {
|
|
24
|
+
return {
|
|
25
|
+
async latest(runtimeRoot, signal): Promise<RegistryRuntimeArtifact> {
|
|
26
|
+
if (!isAbsolute(runtimeRoot)) throw new Error("Runtime root must be absolute.");
|
|
27
|
+
try {
|
|
28
|
+
const existing = await lstat(runtimeRoot);
|
|
29
|
+
if (!existing.isDirectory() || existing.isSymbolicLink()) {
|
|
30
|
+
throw new Error("Runtime root must be a private directory.");
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
34
|
+
await mkdir(runtimeRoot, { recursive: true, mode: 0o700 });
|
|
35
|
+
}
|
|
36
|
+
const root = await realpath(runtimeRoot);
|
|
37
|
+
const rootMetadata = await lstat(root);
|
|
38
|
+
if ((rootMetadata.mode & 0o077) !== 0) throw new Error("Runtime root must be a private directory.");
|
|
39
|
+
const temporary = await mkdtemp(join(root, ".download-"));
|
|
40
|
+
try {
|
|
41
|
+
const metadataResponse = await request(`${registryOrigin}/borgmcp-server/latest`, {
|
|
42
|
+
headers: { accept: "application/json" },
|
|
43
|
+
redirect: "error",
|
|
44
|
+
signal,
|
|
45
|
+
});
|
|
46
|
+
if (!metadataResponse.ok || metadataResponse.url !== `${registryOrigin}/borgmcp-server/latest`) {
|
|
47
|
+
throw new Error("Server artifact metadata verification failed.");
|
|
48
|
+
}
|
|
49
|
+
const metadataBytes = await readBounded(metadataResponse, 64 * 1024);
|
|
50
|
+
const metadata = JSON.parse(metadataBytes.toString("utf8")) as {
|
|
51
|
+
name?: unknown;
|
|
52
|
+
version?: unknown;
|
|
53
|
+
gitHead?: unknown;
|
|
54
|
+
dist?: { integrity?: unknown; tarball?: unknown };
|
|
55
|
+
};
|
|
56
|
+
if (metadata.name !== "borgmcp-server" || typeof metadata.version !== "string" ||
|
|
57
|
+
!exactVersion.test(metadata.version) || typeof metadata.dist?.integrity !== "string" ||
|
|
58
|
+
!integrityPattern.test(metadata.dist.integrity)) {
|
|
59
|
+
throw new Error("Server artifact metadata verification failed.");
|
|
60
|
+
}
|
|
61
|
+
const canonicalTarball = `${registryOrigin}/borgmcp-server/-/borgmcp-server-${metadata.version}.tgz`;
|
|
62
|
+
if (metadata.dist.tarball !== canonicalTarball ||
|
|
63
|
+
(metadata.gitHead !== undefined &&
|
|
64
|
+
(typeof metadata.gitHead !== "string" || !sourceShaPattern.test(metadata.gitHead)))) {
|
|
65
|
+
throw new Error("Server artifact metadata verification failed.");
|
|
66
|
+
}
|
|
67
|
+
const artifactResponse = await request(canonicalTarball, { redirect: "error", signal });
|
|
68
|
+
if (!artifactResponse.ok || artifactResponse.url !== canonicalTarball) {
|
|
69
|
+
throw new Error("Server artifact download failed.");
|
|
70
|
+
}
|
|
71
|
+
const artifact = await readBounded(artifactResponse, 2 * 1024 * 1024);
|
|
72
|
+
const tarballPath = join(temporary, `borgmcp-server-${metadata.version}.tgz`);
|
|
73
|
+
await writeFile(tarballPath, artifact, { flag: "wx", mode: 0o600 });
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
tarballPath,
|
|
76
|
+
version: metadata.version,
|
|
77
|
+
integrity: metadata.dist.integrity,
|
|
78
|
+
sourceSha: typeof metadata.gitHead === "string" ? metadata.gitHead : null,
|
|
79
|
+
cleanup: () => rm(temporary, { recursive: true, force: true }),
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await rm(temporary, { recursive: true, force: true });
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readBounded(response: Response, limit: number): Promise<Buffer> {
|
|
90
|
+
const declared = response.headers.get("content-length");
|
|
91
|
+
if (declared !== null && (!/^[0-9]+$/u.test(declared) || Number(declared) > limit)) {
|
|
92
|
+
throw new Error("Server artifact response exceeded its bound.");
|
|
93
|
+
}
|
|
94
|
+
if (response.body === null) throw new Error("Server artifact response is empty.");
|
|
95
|
+
const reader = response.body.getReader();
|
|
96
|
+
const chunks: Buffer[] = [];
|
|
97
|
+
let bytes = 0;
|
|
98
|
+
try {
|
|
99
|
+
while (true) {
|
|
100
|
+
const next = await reader.read();
|
|
101
|
+
if (next.done) break;
|
|
102
|
+
bytes += next.value.byteLength;
|
|
103
|
+
if (bytes > limit) throw new Error("Server artifact response exceeded its bound.");
|
|
104
|
+
chunks.push(Buffer.from(next.value));
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
await reader.cancel().catch(() => undefined);
|
|
108
|
+
reader.releaseLock();
|
|
109
|
+
}
|
|
110
|
+
return Buffer.concat(chunks, bytes);
|
|
111
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "borgmcp-shared/protocol";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export const SERVER_PACKAGE_VERSION = "0.1.9";
|
|
5
|
+
export const RUNTIME_INFO_PATH = "/api/runtime";
|
|
6
|
+
|
|
7
|
+
export interface RuntimeBuildIdentity {
|
|
8
|
+
readonly package_version: string;
|
|
9
|
+
readonly source_sha: string | null;
|
|
10
|
+
readonly artifact_integrity: string | null;
|
|
11
|
+
readonly protocol_version: string;
|
|
12
|
+
readonly started_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RuntimeBuildIdentityInput {
|
|
16
|
+
readonly sourceSha?: string;
|
|
17
|
+
readonly artifactIntegrity?: string;
|
|
18
|
+
readonly startedAt?: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LoadRuntimeBuildIdentityInput extends RuntimeBuildIdentityInput {
|
|
22
|
+
readonly artifactDescriptorPath?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sourceShaPattern = /^[0-9a-f]{40}$/u;
|
|
26
|
+
const artifactIntegrityPattern = /^sha512-[A-Za-z0-9+/]{86}==$/u;
|
|
27
|
+
|
|
28
|
+
export function createRuntimeBuildIdentity(
|
|
29
|
+
input: RuntimeBuildIdentityInput = {},
|
|
30
|
+
): RuntimeBuildIdentity {
|
|
31
|
+
const sourceSha = input.sourceSha;
|
|
32
|
+
const artifactIntegrity = input.artifactIntegrity;
|
|
33
|
+
if (sourceSha !== undefined && !sourceShaPattern.test(sourceSha)) {
|
|
34
|
+
throw new Error("Server source identity is invalid.");
|
|
35
|
+
}
|
|
36
|
+
if (artifactIntegrity !== undefined && !artifactIntegrityPattern.test(artifactIntegrity)) {
|
|
37
|
+
throw new Error("Server artifact identity is invalid.");
|
|
38
|
+
}
|
|
39
|
+
const startedAt = input.startedAt ?? new Date();
|
|
40
|
+
if (!Number.isFinite(startedAt.getTime())) throw new Error("Server start time is invalid.");
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
package_version: SERVER_PACKAGE_VERSION,
|
|
43
|
+
source_sha: sourceSha ?? null,
|
|
44
|
+
artifact_integrity: artifactIntegrity ?? null,
|
|
45
|
+
protocol_version: PROTOCOL_VERSION,
|
|
46
|
+
started_at: startedAt.toISOString(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function loadRuntimeBuildIdentity(
|
|
51
|
+
input: LoadRuntimeBuildIdentityInput = {},
|
|
52
|
+
): Promise<RuntimeBuildIdentity> {
|
|
53
|
+
if (input.artifactDescriptorPath === undefined) return createRuntimeBuildIdentity(input);
|
|
54
|
+
let descriptor: { version?: unknown; integrity?: unknown; source_sha?: unknown };
|
|
55
|
+
try {
|
|
56
|
+
const metadata = await lstat(input.artifactDescriptorPath);
|
|
57
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 4 * 1024) {
|
|
58
|
+
throw new Error("invalid descriptor");
|
|
59
|
+
}
|
|
60
|
+
descriptor = JSON.parse(await readFile(input.artifactDescriptorPath, "utf8")) as typeof descriptor;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return createRuntimeBuildIdentity(input);
|
|
63
|
+
throw new Error("Runtime artifact descriptor is invalid.");
|
|
64
|
+
}
|
|
65
|
+
if (descriptor.version !== SERVER_PACKAGE_VERSION ||
|
|
66
|
+
typeof descriptor.integrity !== "string" || !artifactIntegrityPattern.test(descriptor.integrity) ||
|
|
67
|
+
(descriptor.source_sha !== null &&
|
|
68
|
+
(typeof descriptor.source_sha !== "string" || !sourceShaPattern.test(descriptor.source_sha)))) {
|
|
69
|
+
throw new Error("Runtime artifact descriptor is invalid.");
|
|
70
|
+
}
|
|
71
|
+
if (input.artifactIntegrity !== undefined && input.artifactIntegrity !== descriptor.integrity) {
|
|
72
|
+
throw new Error("Runtime artifact identity conflicts with the activated artifact.");
|
|
73
|
+
}
|
|
74
|
+
if (input.sourceSha !== undefined && input.sourceSha !== descriptor.source_sha) {
|
|
75
|
+
throw new Error("Runtime source identity conflicts with the activated artifact.");
|
|
76
|
+
}
|
|
77
|
+
return createRuntimeBuildIdentity({
|
|
78
|
+
artifactIntegrity: descriptor.integrity,
|
|
79
|
+
...(descriptor.source_sha === null ? {} : { sourceSha: descriptor.source_sha }),
|
|
80
|
+
...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }),
|
|
81
|
+
});
|
|
82
|
+
}
|