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
package/src/bootstrap.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID, X509Certificate } from "node:crypto";
|
|
2
|
-
import { lstat, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { generate } from "selfsigned";
|
|
5
5
|
|
|
6
|
-
import { CredentialAuthority, CredentialDigester } from "./credentials.js";
|
|
6
|
+
import { CredentialAuthority, CredentialDigester, generateSecret } from "./credentials.js";
|
|
7
|
+
import type { PortableServerCredential } from "./portable-credential-store.js";
|
|
7
8
|
import { openStore } from "./store.js";
|
|
8
9
|
|
|
9
10
|
export interface BootstrapResult {
|
|
10
11
|
readonly serverId: string;
|
|
11
12
|
readonly caFingerprint: string;
|
|
13
|
+
/** @deprecated Retained for offline administrative compatibility; never rendered by setup. */
|
|
12
14
|
readonly recoveryCredential: string;
|
|
15
|
+
/** @deprecated The already-consumed bootstrap invitation; never rendered by setup. */
|
|
13
16
|
readonly initialInvitation: string;
|
|
17
|
+
readonly ownerAccess: {
|
|
18
|
+
readonly clientId: string;
|
|
19
|
+
readonly serverCapabilities: readonly ["create_cube"];
|
|
20
|
+
};
|
|
14
21
|
readonly paths: {
|
|
15
22
|
readonly database: string;
|
|
16
23
|
readonly digestKey: string;
|
|
@@ -26,6 +33,7 @@ export async function bootstrapServer(
|
|
|
26
33
|
dataDirectory: string,
|
|
27
34
|
bindHost = "127.0.0.1",
|
|
28
35
|
clock: () => Date = () => new Date(),
|
|
36
|
+
persistOwnerCredential: (record: PortableServerCredential) => Promise<void> = async () => undefined,
|
|
29
37
|
): Promise<BootstrapResult> {
|
|
30
38
|
const directory = resolve(dataDirectory);
|
|
31
39
|
const paths = {
|
|
@@ -63,6 +71,7 @@ export async function bootstrapServer(
|
|
|
63
71
|
.digest("hex");
|
|
64
72
|
const digestKey = randomBytes(32);
|
|
65
73
|
const runtime = await openStore({ path: paths.database, clock });
|
|
74
|
+
let completed = false;
|
|
66
75
|
try {
|
|
67
76
|
await Promise.all([
|
|
68
77
|
writePrivate(paths.digestKey, digestKey),
|
|
@@ -81,20 +90,52 @@ export async function bootstrapServer(
|
|
|
81
90
|
try {
|
|
82
91
|
const authority = new CredentialAuthority(runtime.credentials, digester, clock);
|
|
83
92
|
const recoveryCredential = authority.createRecoveryCredential();
|
|
84
|
-
const
|
|
85
|
-
|
|
93
|
+
const invitation = authority.createBootstrapInvitation(15 * 60_000);
|
|
94
|
+
const credential = generateSecret();
|
|
95
|
+
const enrollment = authority.exchangeInvitation({
|
|
96
|
+
invitation,
|
|
97
|
+
retryKey: randomUUID(),
|
|
98
|
+
clientCredential: credential,
|
|
99
|
+
clientName: "Local owner",
|
|
100
|
+
});
|
|
101
|
+
if (enrollment?.purpose !== "owner" || enrollment.serverCapabilities[0] !== "create_cube") {
|
|
102
|
+
throw new Error("Local owner provisioning failed.");
|
|
103
|
+
}
|
|
104
|
+
await persistOwnerCredential(Object.freeze({
|
|
105
|
+
version: 2,
|
|
106
|
+
origin: `https://${bindHost === "::1" ? "[::1]" : bindHost}:7091`,
|
|
107
|
+
trustIdentity: `spki-sha256:${caFingerprint}`,
|
|
108
|
+
credential,
|
|
109
|
+
clientId: enrollment.clientId,
|
|
110
|
+
serverCapabilities: ["create_cube"] as const,
|
|
111
|
+
}));
|
|
112
|
+
const result = {
|
|
86
113
|
serverId,
|
|
87
114
|
caFingerprint,
|
|
88
115
|
recoveryCredential,
|
|
89
|
-
initialInvitation,
|
|
116
|
+
initialInvitation: invitation,
|
|
117
|
+
ownerAccess: {
|
|
118
|
+
clientId: enrollment.clientId,
|
|
119
|
+
serverCapabilities: ["create_cube"] as const,
|
|
120
|
+
},
|
|
90
121
|
paths,
|
|
91
122
|
};
|
|
123
|
+
completed = true;
|
|
124
|
+
return result;
|
|
92
125
|
} finally {
|
|
93
126
|
digester.destroy();
|
|
94
127
|
}
|
|
95
128
|
} finally {
|
|
96
129
|
digestKey.fill(0);
|
|
97
130
|
runtime.close();
|
|
131
|
+
if (!completed) {
|
|
132
|
+
await Promise.all([
|
|
133
|
+
...Object.values(paths),
|
|
134
|
+
`${paths.database}-wal`,
|
|
135
|
+
`${paths.database}-shm`,
|
|
136
|
+
`${paths.database}-journal`,
|
|
137
|
+
].map((path) => unlink(path).catch(() => undefined)));
|
|
138
|
+
}
|
|
98
139
|
}
|
|
99
140
|
}
|
|
100
141
|
|
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ServerService } from "./service.js";
|
|
2
|
+
import { RuntimeUpdateFailure } from "./runtime-operator.js";
|
|
2
3
|
|
|
3
4
|
export interface CliIo {
|
|
4
5
|
readonly stdout: (message: string) => void;
|
|
5
6
|
readonly stderr: (message: string) => void;
|
|
6
7
|
readonly readSecret?: (prompt: string) => Promise<string>;
|
|
8
|
+
readonly isTTY?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
const usage = `Usage: borg-mcp-server <command> [options]
|
|
@@ -11,6 +13,9 @@ const usage = `Usage: borg-mcp-server <command> [options]
|
|
|
11
13
|
Commands:
|
|
12
14
|
setup [--reinitialize] Prepare an offline server installation
|
|
13
15
|
start Start the server process
|
|
16
|
+
status [--json] Report exact local runtime evidence
|
|
17
|
+
update [--json] Verify and activate the latest server artifact
|
|
18
|
+
invite Create a single-use invitation in an interactive terminal.
|
|
14
19
|
client-rotate <client-id> Rotate one client credential offline
|
|
15
20
|
client-revoke <client-id> Revoke one client and its credentials offline
|
|
16
21
|
client-grant <client-id> <cube-id> <read|write|manage> Set one offline cube grant
|
|
@@ -58,11 +63,109 @@ export async function runCli(
|
|
|
58
63
|
return 1;
|
|
59
64
|
}
|
|
60
65
|
const result = await service.setup({ reinitialize: extraArgs[0] === "--reinitialize" });
|
|
61
|
-
|
|
66
|
+
const artifactIdentity = result.artifact === undefined
|
|
67
|
+
? "borgmcp-server@unavailable"
|
|
68
|
+
: `borgmcp-server@${result.artifact.version}`;
|
|
69
|
+
const artifact = result.artifact === undefined
|
|
70
|
+
? "Artifact: unavailable"
|
|
71
|
+
: `Artifact: ${artifactIdentity} (${result.artifact.integrity})`;
|
|
72
|
+
if (io.isTTY === false) {
|
|
73
|
+
io.stdout(JSON.stringify({
|
|
74
|
+
status: "prepared",
|
|
75
|
+
artifact: artifactIdentity,
|
|
76
|
+
build_identity: result.artifact?.sourceSha ?? null,
|
|
77
|
+
owner_access: "prepared",
|
|
78
|
+
process: "stopped",
|
|
79
|
+
}));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if ("existing" in result) {
|
|
83
|
+
io.stdout([
|
|
84
|
+
"Local server is already prepared.",
|
|
85
|
+
artifact,
|
|
86
|
+
`Build identity: ${result.artifact?.sourceSha ?? "unavailable"}`,
|
|
87
|
+
"Data and identity: unchanged",
|
|
88
|
+
"No server process started.",
|
|
89
|
+
"Next: borg-mcp-server start",
|
|
90
|
+
].join("\n"));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
io.stdout([
|
|
94
|
+
"Local server setup completed.",
|
|
95
|
+
artifact,
|
|
96
|
+
"Local owner access: prepared.",
|
|
97
|
+
"No server process started.",
|
|
98
|
+
"Next: start the server, then run borg assimilate.",
|
|
99
|
+
].join("\n"));
|
|
62
100
|
return 0;
|
|
63
101
|
case "start":
|
|
64
102
|
await service.start(extraArgs);
|
|
65
103
|
return 0;
|
|
104
|
+
case "status": {
|
|
105
|
+
if (extraArgs.length > 1 || (extraArgs.length === 1 && extraArgs[0] !== "--json") ||
|
|
106
|
+
service.status === undefined) return invalidArguments(io);
|
|
107
|
+
const status = await service.status();
|
|
108
|
+
if (extraArgs[0] === "--json" || io.isTTY === false) {
|
|
109
|
+
io.stdout(JSON.stringify({
|
|
110
|
+
status: status.status,
|
|
111
|
+
artifact: status.artifact === null
|
|
112
|
+
? null
|
|
113
|
+
: `borgmcp-server@${status.artifact.version}`,
|
|
114
|
+
artifact_integrity: status.artifact?.integrity ?? null,
|
|
115
|
+
build_identity: status.buildIdentity,
|
|
116
|
+
endpoint: status.endpoint,
|
|
117
|
+
mode: status.mode,
|
|
118
|
+
data_identity: status.dataIdentity,
|
|
119
|
+
}));
|
|
120
|
+
} else {
|
|
121
|
+
io.stdout(renderRuntimeStatus(status));
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
case "update": {
|
|
126
|
+
if (extraArgs.length > 1 || (extraArgs.length === 1 && extraArgs[0] !== "--json") ||
|
|
127
|
+
service.update === undefined) return invalidArguments(io);
|
|
128
|
+
let result: Awaited<ReturnType<NonNullable<ServerService["update"]>>>;
|
|
129
|
+
try {
|
|
130
|
+
result = await service.update();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!(error instanceof RuntimeUpdateFailure)) throw error;
|
|
133
|
+
renderUpdateFailure(error, io, extraArgs[0] === "--json" || io.isTTY === false);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
if (extraArgs[0] === "--json" || io.isTTY === false) {
|
|
137
|
+
io.stdout(JSON.stringify({
|
|
138
|
+
status: result.outcome,
|
|
139
|
+
artifact: `borgmcp-server@${result.artifact.version}`,
|
|
140
|
+
artifact_integrity: result.artifact.integrity,
|
|
141
|
+
build_identity: result.runningIdentity?.source_sha ?? result.artifact.sourceSha,
|
|
142
|
+
mode: result.outcome === "updated" ? "managed" : "stopped",
|
|
143
|
+
data_identity: result.dataIdentity,
|
|
144
|
+
}));
|
|
145
|
+
} else if (result.outcome === "updated") {
|
|
146
|
+
io.stdout([
|
|
147
|
+
`Verifying borgmcp-server@${result.artifact.version}...`,
|
|
148
|
+
"Artifact verified and activated.",
|
|
149
|
+
"Restarting the verified local server...",
|
|
150
|
+
`Local server is running.`,
|
|
151
|
+
`Artifact: borgmcp-server@${result.artifact.version} (${result.artifact.integrity})`,
|
|
152
|
+
`Build identity: ${result.runningIdentity?.source_sha ?? "unavailable"}`,
|
|
153
|
+
"Data and identity: preserved",
|
|
154
|
+
"Next: borg-mcp-server status",
|
|
155
|
+
].join("\n"));
|
|
156
|
+
} else {
|
|
157
|
+
io.stdout([
|
|
158
|
+
`Verifying borgmcp-server@${result.artifact.version}...`,
|
|
159
|
+
"Artifact verified and activated.",
|
|
160
|
+
"No server process started.",
|
|
161
|
+
`Artifact: borgmcp-server@${result.artifact.version} (${result.artifact.integrity})`,
|
|
162
|
+
`Build identity: ${result.artifact.sourceSha ?? "unavailable"}`,
|
|
163
|
+
"Data and identity: preserved",
|
|
164
|
+
"Next: borg-mcp-server start",
|
|
165
|
+
].join("\n"));
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
66
169
|
case "client-rotate":
|
|
67
170
|
if (extraArgs.length !== 1 || service.rotateClient === undefined) return invalidArguments(io);
|
|
68
171
|
io.stdout(`Client credential rotated (shown once): ${await service.rotateClient(extraArgs[0]!)}`);
|
|
@@ -115,6 +218,16 @@ export async function runCli(
|
|
|
115
218
|
}
|
|
116
219
|
return 0;
|
|
117
220
|
}
|
|
221
|
+
case "invite": {
|
|
222
|
+
if (extraArgs.length !== 0 || service.invite === undefined) return invalidArguments(io);
|
|
223
|
+
if (io.isTTY !== true) {
|
|
224
|
+
io.stderr("Invitation creation requires an interactive terminal.");
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
const invitation = await service.invite();
|
|
228
|
+
io.stdout(`Invitation (single-use; shown once): ${invitation}\nShare it only with the intended recipient.`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
118
231
|
case "help":
|
|
119
232
|
case "--help":
|
|
120
233
|
case "-h":
|
|
@@ -127,6 +240,63 @@ export async function runCli(
|
|
|
127
240
|
}
|
|
128
241
|
}
|
|
129
242
|
|
|
243
|
+
function renderUpdateFailure(failure: RuntimeUpdateFailure, io: CliIo, machine: boolean): void {
|
|
244
|
+
const state = failure.code === "ARTIFACT_VERIFICATION_FAILED"
|
|
245
|
+
? "verification_failed"
|
|
246
|
+
: failure.recovery === "restored"
|
|
247
|
+
? "restored"
|
|
248
|
+
: failure.recovery === "stopped"
|
|
249
|
+
? "stopped"
|
|
250
|
+
: "recovery_failed";
|
|
251
|
+
if (machine) {
|
|
252
|
+
io.stdout(JSON.stringify({
|
|
253
|
+
status: "failed",
|
|
254
|
+
error_code: failure.code,
|
|
255
|
+
recovery: state,
|
|
256
|
+
data_identity: "preserved",
|
|
257
|
+
}));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (failure.code === "ARTIFACT_VERIFICATION_FAILED") {
|
|
261
|
+
io.stderr([
|
|
262
|
+
"Update stopped: artifact verification failed.",
|
|
263
|
+
"No activation occurred.",
|
|
264
|
+
"The last verified runtime remains available.",
|
|
265
|
+
"Next: borg-mcp-server status",
|
|
266
|
+
].join("\n"));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
io.stderr([
|
|
270
|
+
"Update stopped: activation did not complete.",
|
|
271
|
+
failure.recovery === "restored"
|
|
272
|
+
? "The last verified runtime was restored."
|
|
273
|
+
: failure.recovery === "stopped"
|
|
274
|
+
? "The server stopped safely."
|
|
275
|
+
: "Recovery did not complete; inspect server status.",
|
|
276
|
+
"Data and identity: preserved",
|
|
277
|
+
"Next: borg-mcp-server status",
|
|
278
|
+
].join("\n"));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderRuntimeStatus(status: Awaited<ReturnType<NonNullable<ServerService["status"]>>>): string {
|
|
282
|
+
const heading = status.status === "running"
|
|
283
|
+
? status.buildIdentity === null
|
|
284
|
+
? "Local server is reachable, but its running build identity is unavailable."
|
|
285
|
+
: "Local server is running."
|
|
286
|
+
: "Local server is stopped.";
|
|
287
|
+
const artifact = status.artifact === null
|
|
288
|
+
? "Artifact: unavailable"
|
|
289
|
+
: `Artifact: borgmcp-server@${status.artifact.version} (${status.artifact.integrity})`;
|
|
290
|
+
return [
|
|
291
|
+
heading,
|
|
292
|
+
artifact,
|
|
293
|
+
`Build identity: ${status.buildIdentity ?? "unavailable"}`,
|
|
294
|
+
`Endpoint: ${status.endpoint ?? "unavailable"}`,
|
|
295
|
+
`Mode: ${status.mode}`,
|
|
296
|
+
`Data and identity: ${status.dataIdentity}`,
|
|
297
|
+
].join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
130
300
|
function parseClientInviteArguments(
|
|
131
301
|
args: readonly string[],
|
|
132
302
|
): { readonly cubeSelector?: string; readonly access?: "read" | "write" | "manage" } | undefined {
|
package/src/credentials.ts
CHANGED
|
@@ -210,6 +210,13 @@ export class CredentialAuthority {
|
|
|
210
210
|
return this.#createInvitation("client", ttlMs);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
createInvitationForOwnerCredential(clientCredential: string, ttlMs: number): string | null {
|
|
214
|
+
const principal = this.authenticate(`Bearer ${clientCredential}`);
|
|
215
|
+
if (principal?.kind !== "client" ||
|
|
216
|
+
!this.#store.clientHasServerCapability(principal.id, "create_cube")) return null;
|
|
217
|
+
return this.#createInvitation("client", ttlMs);
|
|
218
|
+
}
|
|
219
|
+
|
|
213
220
|
createCubeInvitation(
|
|
214
221
|
recoveryCredential: string,
|
|
215
222
|
cubeSelector: { readonly kind: "id" | "name"; readonly value: string },
|
package/src/debug-log.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Principal } from "./principal.js";
|
|
|
3
3
|
export type DebugRoute =
|
|
4
4
|
| "health"
|
|
5
5
|
| "protocol"
|
|
6
|
+
| "runtime"
|
|
6
7
|
| "enrollment_exchange"
|
|
7
8
|
| "client_attach"
|
|
8
9
|
| "cubes"
|
|
@@ -158,7 +159,7 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
161
|
-
const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
|
|
162
|
+
const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "runtime", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
|
|
162
163
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
|
|
163
164
|
const AUTH_RESULTS = ["not_required", "missing", "invalid", "expired", "revoked", "evicted", "rejected", "accepted"] as const;
|
|
164
165
|
const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
|
package/src/https-server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
|
|
|
17
17
|
import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
|
|
18
18
|
import type { Principal } from "./principal.js";
|
|
19
19
|
import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
|
|
20
|
+
import { RUNTIME_INFO_PATH, type RuntimeBuildIdentity } from "./runtime-identity.js";
|
|
20
21
|
|
|
21
22
|
export interface ServiceLimits {
|
|
22
23
|
readonly maxConnections: number;
|
|
@@ -66,6 +67,7 @@ export interface RequestHandlerContext {
|
|
|
66
67
|
) => Promise<Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected">;
|
|
67
68
|
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
68
69
|
readonly debugLogger: DebugLogger;
|
|
70
|
+
readonly runtimeIdentity?: RuntimeBuildIdentity;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export interface HttpsServerOptions {
|
|
@@ -80,6 +82,7 @@ export interface HttpsServerOptions {
|
|
|
80
82
|
readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
|
|
81
83
|
readonly limits?: ServiceLimits;
|
|
82
84
|
readonly debugLogger?: DebugLogger;
|
|
85
|
+
readonly runtimeIdentity?: RuntimeBuildIdentity;
|
|
83
86
|
readonly testHooks?: {
|
|
84
87
|
readonly identifyRemoteAddress?: (socket: Socket) => string;
|
|
85
88
|
readonly identifyConnectionAddress?: (socket: Socket) => string;
|
|
@@ -183,6 +186,7 @@ export function createRequestHandlerContext(
|
|
|
183
186
|
...(options.handleCoordination === undefined
|
|
184
187
|
? {}
|
|
185
188
|
: { handleCoordination: options.handleCoordination }),
|
|
189
|
+
...(options.runtimeIdentity === undefined ? {} : { runtimeIdentity: options.runtimeIdentity }),
|
|
186
190
|
debugLogger: options.debugLogger ?? disabledDebugLogger,
|
|
187
191
|
});
|
|
188
192
|
}
|
|
@@ -342,7 +346,7 @@ async function handleRequest(
|
|
|
342
346
|
return;
|
|
343
347
|
}
|
|
344
348
|
|
|
345
|
-
if (
|
|
349
|
+
if (isAuthenticatedPath(path) && context.authorizeCoordination !== undefined) {
|
|
346
350
|
let decoded: unknown;
|
|
347
351
|
if (requestBody.length === 0) {
|
|
348
352
|
decoded = undefined;
|
|
@@ -354,12 +358,7 @@ async function handleRequest(
|
|
|
354
358
|
}
|
|
355
359
|
}
|
|
356
360
|
const authorization = request.headers.authorization;
|
|
357
|
-
const
|
|
358
|
-
if (authorizeCoordination === undefined) {
|
|
359
|
-
sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination authentication is unavailable."), true);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
const authentication = await authorizeCoordination(authorization, signal);
|
|
361
|
+
const authentication = await context.authorizeCoordination(authorization, signal);
|
|
363
362
|
trace.authentication = typeof authentication === "string" ? authentication : "accepted";
|
|
364
363
|
if (signal.aborted) return;
|
|
365
364
|
if (typeof authentication === "string") {
|
|
@@ -376,6 +375,22 @@ async function handleRequest(
|
|
|
376
375
|
return;
|
|
377
376
|
}
|
|
378
377
|
trace.principal = authentication;
|
|
378
|
+
if (path === RUNTIME_INFO_PATH) {
|
|
379
|
+
if (request.method !== "GET" || requestBody.length !== 0) {
|
|
380
|
+
sendEmpty(response, request.method === "GET" ? 400 : 405, true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (context.runtimeIdentity === undefined) {
|
|
384
|
+
sendEmpty(response, 404);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
sendJson(response, 200, context.runtimeIdentity);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (context.handleCoordination === undefined) {
|
|
391
|
+
sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination handling is unavailable."), true);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
379
394
|
const clientIdentity = credentialRateLimitIdentity(authentication, request.method, path);
|
|
380
395
|
const credentialRetry = credentialRateLimiter.consume(clientIdentity);
|
|
381
396
|
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
@@ -431,6 +446,7 @@ function debugRoute(rawUrl: string | undefined): DebugRoute {
|
|
|
431
446
|
if (path === null) return "unknown";
|
|
432
447
|
if (path === HEALTH_PATH) return "health";
|
|
433
448
|
if (path === PROTOCOL_INFO_PATH) return "protocol";
|
|
449
|
+
if (path === RUNTIME_INFO_PATH) return "runtime";
|
|
434
450
|
if (path === ENROLLMENT_EXCHANGE_PATH) return "enrollment_exchange";
|
|
435
451
|
if (path === ATTACH_PATH) return "client_attach";
|
|
436
452
|
if (path === "/api/cubes") return "cubes";
|
|
@@ -616,6 +632,10 @@ function isCoordinationPath(path: string | null): path is string {
|
|
|
616
632
|
path?.startsWith("/api/cubes/") === true;
|
|
617
633
|
}
|
|
618
634
|
|
|
635
|
+
function isAuthenticatedPath(path: string | null): path is string {
|
|
636
|
+
return path === RUNTIME_INFO_PATH || isCoordinationPath(path);
|
|
637
|
+
}
|
|
638
|
+
|
|
619
639
|
function credentialRateLimitIdentity(
|
|
620
640
|
principal: Principal,
|
|
621
641
|
method: string | undefined,
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
1
|
export { runCli } from "./cli.js";
|
|
2
2
|
export type { CliIo } from "./cli.js";
|
|
3
|
-
export
|
|
3
|
+
export { inspectRuntimeLock } from "./service.js";
|
|
4
|
+
export { inspectNodeRuntime } from "./service.js";
|
|
5
|
+
export type {
|
|
6
|
+
RuntimeLockStatus,
|
|
7
|
+
ServerRuntimeStatus,
|
|
8
|
+
ServerSetupResult,
|
|
9
|
+
ServerService,
|
|
10
|
+
SetupOptions,
|
|
11
|
+
} from "./service.js";
|
|
12
|
+
export {
|
|
13
|
+
createRuntimeBuildIdentity,
|
|
14
|
+
loadRuntimeBuildIdentity,
|
|
15
|
+
RUNTIME_INFO_PATH,
|
|
16
|
+
SERVER_PACKAGE_VERSION,
|
|
17
|
+
} from "./runtime-identity.js";
|
|
18
|
+
export type {
|
|
19
|
+
LoadRuntimeBuildIdentityInput,
|
|
20
|
+
RuntimeBuildIdentity,
|
|
21
|
+
RuntimeBuildIdentityInput,
|
|
22
|
+
} from "./runtime-identity.js";
|
|
23
|
+
export { createManagedServiceDefinition } from "./managed-service.js";
|
|
24
|
+
export type {
|
|
25
|
+
ManagedServiceDefinition,
|
|
26
|
+
ManagedServiceInput,
|
|
27
|
+
ManagedServicePlatform,
|
|
28
|
+
} from "./managed-service.js";
|
|
29
|
+
export {
|
|
30
|
+
createRuntimeLifecycle,
|
|
31
|
+
RuntimeActivationError,
|
|
32
|
+
createUnixNpmArtifactUnpacker,
|
|
33
|
+
inspectActiveRuntimeArtifact,
|
|
34
|
+
} from "./runtime-lifecycle.js";
|
|
35
|
+
export { createRegistryArtifactSource } from "./registry-artifact.js";
|
|
36
|
+
export type { RegistryArtifactSource, RegistryRuntimeArtifact } from "./registry-artifact.js";
|
|
37
|
+
export { createRuntimeOperator } from "./runtime-operator.js";
|
|
38
|
+
export { RuntimeUpdateFailure } from "./runtime-operator.js";
|
|
39
|
+
export type { RuntimeOperator, RuntimeUpdateResult } from "./runtime-operator.js";
|
|
40
|
+
export type {
|
|
41
|
+
ActivateRuntimeArtifactInput,
|
|
42
|
+
RuntimeLifecycle,
|
|
43
|
+
RuntimeLifecycleDependencies,
|
|
44
|
+
RuntimeCommandResult,
|
|
45
|
+
RuntimeCommandRunner,
|
|
46
|
+
StageRuntimeArtifactInput,
|
|
47
|
+
VerifiedRuntimeArtifact,
|
|
48
|
+
} from "./runtime-lifecycle.js";
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ const io = {
|
|
|
10
10
|
stdout: (message: string): void => console.log(message),
|
|
11
11
|
stderr: (message: string): void => console.error(message),
|
|
12
12
|
readSecret: readHiddenSecret,
|
|
13
|
+
isTTY: process.stdout.isTTY === true,
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
async function readHiddenSecret(prompt: string): Promise<string> {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export type ManagedServicePlatform = "launchd" | "systemd";
|
|
4
|
+
|
|
5
|
+
export interface ManagedServiceInput {
|
|
6
|
+
readonly platform: ManagedServicePlatform;
|
|
7
|
+
readonly nodeExecutable: string;
|
|
8
|
+
readonly runtimeRoot: string;
|
|
9
|
+
readonly dataDirectory: string;
|
|
10
|
+
readonly definitionPath: string;
|
|
11
|
+
readonly launchdDomain?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ManagedServiceDefinition {
|
|
15
|
+
readonly platform: ManagedServicePlatform;
|
|
16
|
+
readonly label: "ai.borgmcp.server";
|
|
17
|
+
readonly definitionPath: string;
|
|
18
|
+
readonly content: string;
|
|
19
|
+
readonly install: readonly [string, ...string[]];
|
|
20
|
+
readonly restart: readonly [string, ...string[]];
|
|
21
|
+
readonly stop: readonly [string, ...string[]];
|
|
22
|
+
readonly status: readonly [string, ...string[]];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const serviceLabel = "ai.borgmcp.server" as const;
|
|
26
|
+
|
|
27
|
+
export function createManagedServiceDefinition(input: ManagedServiceInput): ManagedServiceDefinition {
|
|
28
|
+
for (const path of [
|
|
29
|
+
input.nodeExecutable,
|
|
30
|
+
input.runtimeRoot,
|
|
31
|
+
input.dataDirectory,
|
|
32
|
+
input.definitionPath,
|
|
33
|
+
]) {
|
|
34
|
+
if (!isAbsolute(path) || /[\r\n\0]/u.test(path)) {
|
|
35
|
+
throw new Error("Managed service paths must be absolute and single-line.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const entrypoint = join(input.runtimeRoot, "current", "package", "dist", "main.js");
|
|
39
|
+
if (input.platform === "launchd") {
|
|
40
|
+
const domain = input.launchdDomain;
|
|
41
|
+
if (domain === undefined || !/^gui\/[1-9][0-9]*$/u.test(domain)) {
|
|
42
|
+
throw new Error("Managed launchd domain is invalid.");
|
|
43
|
+
}
|
|
44
|
+
const service = `${domain}/${serviceLabel}`;
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
platform: "launchd",
|
|
47
|
+
label: serviceLabel,
|
|
48
|
+
definitionPath: input.definitionPath,
|
|
49
|
+
content: launchdDefinition(input.nodeExecutable, entrypoint, input.dataDirectory),
|
|
50
|
+
install: ["launchctl", "bootstrap", domain, input.definitionPath] as const,
|
|
51
|
+
restart: ["launchctl", "kickstart", "-k", service] as const,
|
|
52
|
+
stop: ["launchctl", "kill", "SIGTERM", service] as const,
|
|
53
|
+
status: ["launchctl", "print", service] as const,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
platform: "systemd",
|
|
58
|
+
label: serviceLabel,
|
|
59
|
+
definitionPath: input.definitionPath,
|
|
60
|
+
content: systemdDefinition(input.nodeExecutable, entrypoint, input.dataDirectory),
|
|
61
|
+
install: ["systemctl", "--user", "enable", "--now", serviceLabel] as const,
|
|
62
|
+
restart: ["systemctl", "--user", "restart", serviceLabel] as const,
|
|
63
|
+
stop: ["systemctl", "--user", "stop", serviceLabel] as const,
|
|
64
|
+
status: ["systemctl", "--user", "show", serviceLabel, "--property=ActiveState,SubState,MainPID"] as const,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function launchdDefinition(nodeExecutable: string, entrypoint: string, dataDirectory: string): string {
|
|
69
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
70
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
71
|
+
<plist version="1.0"><dict>
|
|
72
|
+
<key>Label</key><string>${serviceLabel}</string>
|
|
73
|
+
<key>ProgramArguments</key><array><string>${xml(nodeExecutable)}</string><string>${xml(entrypoint)}</string><string>start</string></array>
|
|
74
|
+
<key>EnvironmentVariables</key><dict><key>BORG_SERVER_DATA_DIR</key><string>${xml(dataDirectory)}</string><key>BORG_SERVER_PROCESS_MODE</key><string>managed</string></dict>
|
|
75
|
+
<key>RunAtLoad</key><true/>
|
|
76
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
77
|
+
<key>ProcessType</key><string>Background</string>
|
|
78
|
+
</dict></plist>
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function systemdDefinition(nodeExecutable: string, entrypoint: string, dataDirectory: string): string {
|
|
83
|
+
return `[Unit]
|
|
84
|
+
Description=Borg MCP server
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=simple
|
|
88
|
+
ExecStart=${systemdQuote(nodeExecutable)} ${systemdQuote(entrypoint)} start
|
|
89
|
+
Environment=${systemdQuote(`BORG_SERVER_DATA_DIR=${dataDirectory}`)}
|
|
90
|
+
Environment="BORG_SERVER_PROCESS_MODE=managed"
|
|
91
|
+
Restart=on-failure
|
|
92
|
+
RestartSec=2
|
|
93
|
+
TimeoutStopSec=15
|
|
94
|
+
|
|
95
|
+
[Install]
|
|
96
|
+
WantedBy=default.target
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function xml(value: string): string {
|
|
101
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
102
|
+
.replaceAll('"', """).replaceAll("'", "'");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function systemdQuote(value: string): string {
|
|
106
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
107
|
+
}
|