borgmcp 2.8.0 → 2.10.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/dist/assimilate-cmd.d.ts +14 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +187 -14
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +29 -1
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/assimilate-welcome.d.ts.map +1 -1
- package/dist/assimilate-welcome.js +1 -0
- package/dist/assimilate-welcome.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +27 -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 +13 -3
- package/dist/cli-help.js.map +1 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +170 -11
- package/dist/config.js.map +1 -1
- package/dist/first-run-server.d.ts +10 -2
- package/dist/first-run-server.d.ts.map +1 -1
- package/dist/first-run-server.js +54 -7
- package/dist/first-run-server.js.map +1 -1
- package/dist/invitation-artifact.d.ts +33 -0
- package/dist/invitation-artifact.d.ts.map +1 -0
- package/dist/invitation-artifact.js +89 -0
- package/dist/invitation-artifact.js.map +1 -0
- package/dist/launch-gate.d.ts +5 -4
- package/dist/launch-gate.d.ts.map +1 -1
- package/dist/launch-gate.js +7 -8
- package/dist/launch-gate.js.map +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -1
- package/dist/parse-assimilate-args.js +9 -3
- package/dist/parse-assimilate-args.js.map +1 -1
- package/dist/recover-enrollment-cmd.d.ts +17 -0
- package/dist/recover-enrollment-cmd.d.ts.map +1 -0
- package/dist/recover-enrollment-cmd.js +62 -0
- package/dist/recover-enrollment-cmd.js.map +1 -0
- package/dist/regen-format.d.ts +9 -0
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +15 -0
- package/dist/regen-format.js.map +1 -1
- package/dist/regen.js +27 -3
- package/dist/regen.js.map +1 -1
- package/dist/remote-client.d.ts +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +1 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/runtime-metadata.d.ts +1 -1
- package/dist/runtime-metadata.d.ts.map +1 -1
- package/dist/runtime-metadata.js +3 -3
- package/dist/runtime-metadata.js.map +1 -1
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +7 -0
- package/dist/seat-store.js.map +1 -1
- package/dist/server-errors.d.ts +1 -1
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +1 -1
- package/dist/server-facade.d.ts.map +1 -1
- package/dist/server-facade.js +2 -2
- package/dist/server-facade.js.map +1 -1
- package/dist/server-handshake.d.ts +23 -3
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +102 -7
- package/dist/server-handshake.js.map +1 -1
- package/dist/server-trust.d.ts +8 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +165 -5
- package/dist/server-trust.js.map +1 -1
- package/dist/setup.js +5 -2
- package/dist/setup.js.map +1 -1
- package/dist/token-store.d.ts +3 -0
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js +2 -0
- package/dist/token-store.js.map +1 -1
- package/dist/tool-manifest.d.ts.map +1 -1
- package/dist/tool-manifest.js +5 -3
- package/dist/tool-manifest.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/dist/update-cmd.d.ts +1 -0
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +8 -3
- package/dist/update-cmd.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +11 -7
- package/docs/LOCAL_SERVER.md +12 -3
- package/docs/RELEASING.md +17 -6
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +234 -20
- package/src/assimilate-deps.ts +29 -0
- package/src/assimilate-welcome.ts +1 -0
- package/src/claude.ts +22 -0
- package/src/cli-help.ts +16 -3
- package/src/config.ts +167 -8
- package/src/first-run-server.ts +71 -8
- package/src/invitation-artifact.ts +116 -0
- package/src/launch-gate.ts +7 -8
- package/src/parse-assimilate-args.ts +11 -4
- package/src/recover-enrollment-cmd.ts +63 -0
- package/src/regen-format.ts +25 -0
- package/src/regen.ts +30 -2
- package/src/remote-client.ts +2 -2
- package/src/runtime-metadata.ts +4 -3
- package/src/seat-store.ts +6 -0
- package/src/server-errors.ts +1 -0
- package/src/server-facade.ts +2 -2
- package/src/server-handshake.ts +140 -4
- package/src/server-trust.ts +180 -5
- package/src/setup.ts +5 -2
- package/src/token-store.ts +5 -0
- package/src/tool-manifest.ts +5 -3
- package/src/unknown-subcommand.ts +1 -0
- package/src/update-cmd.ts +8 -3
package/src/config.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface PendingServerEnrollmentRecord {
|
|
|
48
48
|
retryKey: string;
|
|
49
49
|
credential: string;
|
|
50
50
|
clientName?: string;
|
|
51
|
+
replacementCapability?: string;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface PendingServerCubeCreationRecord {
|
|
@@ -244,6 +245,7 @@ export function __setServerCredentialBackendForTest(backend: TokenBackend | null
|
|
|
244
245
|
async function writeServerCredentialRecord(
|
|
245
246
|
backend: TokenBackend,
|
|
246
247
|
record: ServerCredentialRecord,
|
|
248
|
+
allowReplacement = false,
|
|
247
249
|
): Promise<void> {
|
|
248
250
|
validateServerCredentialBinding(record.origin, record.trustIdentity);
|
|
249
251
|
validateEnrollmentCredential(record.credential);
|
|
@@ -251,6 +253,49 @@ async function writeServerCredentialRecord(
|
|
|
251
253
|
validateUuid(record.clientId, 'client identity');
|
|
252
254
|
}
|
|
253
255
|
const serverCapabilities = validateServerCapabilities(record.serverCapabilities ?? []);
|
|
256
|
+
const targetAccount = serverCredentialAccount(record.origin, record.trustIdentity);
|
|
257
|
+
const exactExisting = await backend.get(targetAccount);
|
|
258
|
+
if (exactExisting !== null && !allowReplacement) {
|
|
259
|
+
throw new Error('local Borg server enrollment already exists for this origin and replacement was not confirmed');
|
|
260
|
+
}
|
|
261
|
+
if (backend.entries) {
|
|
262
|
+
const accounts = await backend.entries();
|
|
263
|
+
const conflicts = Object.entries(accounts).filter(([account, value]) => {
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(value) as { version?: unknown; origin?: unknown };
|
|
266
|
+
return parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === record.origin;
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
if (conflicts.length > 0 && !allowReplacement) {
|
|
272
|
+
throw new Error('local Borg server enrollment already exists for this origin and replacement was not confirmed');
|
|
273
|
+
}
|
|
274
|
+
if (allowReplacement && conflicts.length > 0) {
|
|
275
|
+
const target = targetAccount;
|
|
276
|
+
const value = JSON.stringify({
|
|
277
|
+
version: SERVER_CREDENTIAL_RECORD_VERSION,
|
|
278
|
+
origin: record.origin,
|
|
279
|
+
trustIdentity: record.trustIdentity,
|
|
280
|
+
credential: record.credential,
|
|
281
|
+
clientId: record.clientId ?? null,
|
|
282
|
+
serverCapabilities,
|
|
283
|
+
});
|
|
284
|
+
if (backend.replaceAccounts) {
|
|
285
|
+
const next = { ...accounts };
|
|
286
|
+
for (const [account] of conflicts) delete next[account];
|
|
287
|
+
next[target] = value;
|
|
288
|
+
await backend.replaceAccounts(next);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Set first so an injected write failure cannot remove the prior record.
|
|
292
|
+
await backend.set(target, value);
|
|
293
|
+
for (const [account] of conflicts) {
|
|
294
|
+
if (account !== target) await backend.delete(account);
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
254
299
|
await backend.set(
|
|
255
300
|
serverCredentialAccount(record.origin, record.trustIdentity),
|
|
256
301
|
JSON.stringify({
|
|
@@ -264,6 +309,17 @@ async function writeServerCredentialRecord(
|
|
|
264
309
|
);
|
|
265
310
|
}
|
|
266
311
|
|
|
312
|
+
function encodeServerCredentialRecord(record: ServerCredentialRecord, serverCapabilities: ServerCapability[]): string {
|
|
313
|
+
return JSON.stringify({
|
|
314
|
+
version: SERVER_CREDENTIAL_RECORD_VERSION,
|
|
315
|
+
origin: record.origin,
|
|
316
|
+
trustIdentity: record.trustIdentity,
|
|
317
|
+
credential: record.credential,
|
|
318
|
+
clientId: record.clientId ?? null,
|
|
319
|
+
serverCapabilities,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
267
323
|
/**
|
|
268
324
|
* Persist one self-hosted server credential in the dedicated 0600 credential store.
|
|
269
325
|
*
|
|
@@ -274,9 +330,12 @@ async function writeServerCredentialRecord(
|
|
|
274
330
|
* not credential sources. CR3b: the load→set→rename runs inside ONE hold of the
|
|
275
331
|
* single store lock so a concurrent writer cannot lose an unrelated account.
|
|
276
332
|
*/
|
|
277
|
-
export async function storeServerCredential(
|
|
333
|
+
export async function storeServerCredential(
|
|
334
|
+
record: ServerCredentialRecord,
|
|
335
|
+
options: { allowReplacement?: boolean } = {},
|
|
336
|
+
): Promise<void> {
|
|
278
337
|
const backend = await getServerCredentialBackend();
|
|
279
|
-
await withCredentialStoreLock(() => writeServerCredentialRecord(backend, record));
|
|
338
|
+
await withCredentialStoreLock(() => writeServerCredentialRecord(backend, record, options.allowReplacement === true));
|
|
280
339
|
}
|
|
281
340
|
|
|
282
341
|
/** Read an authority-bound active client record, failing closed on corruption. */
|
|
@@ -329,6 +388,20 @@ export async function getServerCredential(
|
|
|
329
388
|
return (await getServerCredentialRecord(origin, trustIdentity))?.credential ?? null;
|
|
330
389
|
}
|
|
331
390
|
|
|
391
|
+
export async function hasServerCredentialForOrigin(origin: string): Promise<boolean> {
|
|
392
|
+
const backend = await getServerCredentialBackend();
|
|
393
|
+
if (!backend.entries) return false;
|
|
394
|
+
const accounts = await backend.entries();
|
|
395
|
+
return Object.values(accounts).some((value) => {
|
|
396
|
+
try {
|
|
397
|
+
const record = JSON.parse(value) as { version?: unknown; origin?: unknown };
|
|
398
|
+
return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === origin;
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
332
405
|
function decodePendingServerEnrollment(
|
|
333
406
|
stored: string,
|
|
334
407
|
origin: string,
|
|
@@ -347,6 +420,7 @@ function decodePendingServerEnrollment(
|
|
|
347
420
|
typeof record.retryKey !== 'string' || !UUID_RE.test(record.retryKey) ||
|
|
348
421
|
typeof record.credential !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
|
|
349
422
|
(record.clientName !== undefined && typeof record.clientName !== 'string')
|
|
423
|
+
|| (record.replacementCapability !== undefined && !UUID_RE.test(record.replacementCapability))
|
|
350
424
|
) {
|
|
351
425
|
throw new Error('invalid');
|
|
352
426
|
}
|
|
@@ -359,6 +433,7 @@ function decodePendingServerEnrollment(
|
|
|
359
433
|
retryKey: record.retryKey,
|
|
360
434
|
credential: record.credential,
|
|
361
435
|
...(record.clientName === undefined ? {} : { clientName: record.clientName }),
|
|
436
|
+
...(record.replacementCapability === undefined ? {} : { replacementCapability: record.replacementCapability }),
|
|
362
437
|
};
|
|
363
438
|
}
|
|
364
439
|
|
|
@@ -381,6 +456,30 @@ export async function getPendingServerEnrollment(
|
|
|
381
456
|
});
|
|
382
457
|
}
|
|
383
458
|
|
|
459
|
+
/** Find the sole pending enrollment so artifact-only retries need no invitation. */
|
|
460
|
+
export async function findPendingServerEnrollment(): Promise<PendingServerEnrollmentRecord | null> {
|
|
461
|
+
const backend = await getServerCredentialBackend();
|
|
462
|
+
if (!backend.entries) return null;
|
|
463
|
+
return withCredentialStoreLock(async () => {
|
|
464
|
+
const matches: PendingServerEnrollmentRecord[] = [];
|
|
465
|
+
for (const value of Object.values(await backend.entries!())) {
|
|
466
|
+
try {
|
|
467
|
+
const raw = JSON.parse(value) as Partial<PendingServerEnrollmentRecord> & {
|
|
468
|
+
version?: unknown;
|
|
469
|
+
state?: unknown;
|
|
470
|
+
};
|
|
471
|
+
if (raw.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION || raw.state !== 'pending') continue;
|
|
472
|
+
if (typeof raw.origin !== 'string' || typeof raw.trustIdentity !== 'string') continue;
|
|
473
|
+
matches.push(decodePendingServerEnrollment(value, raw.origin, raw.trustIdentity));
|
|
474
|
+
} catch {
|
|
475
|
+
// Ignore unrelated or corrupt records; the keyed resume path remains fail-closed.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (matches.length > 1) throw new Error('multiple pending Borg server enrollments require an explicit server endpoint');
|
|
479
|
+
return matches[0] ?? null;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
384
483
|
/**
|
|
385
484
|
* Generate and persist an exact enrollment tuple before network I/O. A
|
|
386
485
|
* pre-existing PENDING tuple must match the invitation and presentation name;
|
|
@@ -392,11 +491,13 @@ export async function getOrCreatePendingServerEnrollment(
|
|
|
392
491
|
trustIdentity: string;
|
|
393
492
|
invitation: string;
|
|
394
493
|
clientName?: string;
|
|
494
|
+
replacementCapability?: string;
|
|
395
495
|
},
|
|
396
496
|
): Promise<PendingServerEnrollmentRecord> {
|
|
397
497
|
validateServerCredentialBinding(input.origin, input.trustIdentity);
|
|
398
498
|
validateInvitation(input.invitation);
|
|
399
499
|
validateClientName(input.clientName);
|
|
500
|
+
if (input.replacementCapability !== undefined) validateUuid(input.replacementCapability, 'replacement capability');
|
|
400
501
|
const backend = await getServerCredentialBackend();
|
|
401
502
|
const account = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
|
|
402
503
|
return withCredentialStoreLock(async () => {
|
|
@@ -411,6 +512,7 @@ export async function getOrCreatePendingServerEnrollment(
|
|
|
411
512
|
if (
|
|
412
513
|
record.invitation !== input.invitation ||
|
|
413
514
|
record.clientName !== input.clientName
|
|
515
|
+
|| record.replacementCapability !== input.replacementCapability
|
|
414
516
|
) {
|
|
415
517
|
throw new Error('mismatch');
|
|
416
518
|
}
|
|
@@ -427,6 +529,7 @@ export async function getOrCreatePendingServerEnrollment(
|
|
|
427
529
|
retryKey: randomUUID(),
|
|
428
530
|
credential: randomBytes(32).toString('base64url'),
|
|
429
531
|
...(input.clientName === undefined ? {} : { clientName: input.clientName }),
|
|
532
|
+
...(input.replacementCapability === undefined ? {} : { replacementCapability: input.replacementCapability }),
|
|
430
533
|
};
|
|
431
534
|
validateEnrollmentCredential(record.credential);
|
|
432
535
|
await backend.set(account, JSON.stringify({
|
|
@@ -447,6 +550,8 @@ export async function activatePendingServerEnrollment(
|
|
|
447
550
|
credential: string;
|
|
448
551
|
clientId: string;
|
|
449
552
|
serverCapabilities: ServerCapability[];
|
|
553
|
+
allowReplacement?: boolean;
|
|
554
|
+
replacementCapability?: string;
|
|
450
555
|
},
|
|
451
556
|
): Promise<void> {
|
|
452
557
|
validateServerCredentialBinding(input.origin, input.trustIdentity);
|
|
@@ -465,22 +570,56 @@ export async function activatePendingServerEnrollment(
|
|
|
465
570
|
input.origin,
|
|
466
571
|
input.trustIdentity,
|
|
467
572
|
);
|
|
468
|
-
if (pending.retryKey !== input.retryKey || pending.credential !== input.credential
|
|
573
|
+
if (pending.retryKey !== input.retryKey || pending.credential !== input.credential ||
|
|
574
|
+
pending.replacementCapability !== input.replacementCapability) {
|
|
469
575
|
throw new Error('mismatch');
|
|
470
576
|
}
|
|
471
577
|
} catch {
|
|
472
578
|
throw new Error('pending Borg server enrollment does not match the verified response');
|
|
473
579
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
580
|
+
if (!input.allowReplacement) {
|
|
581
|
+
const existing = backend.entries
|
|
582
|
+
? Object.values(await backend.entries()).some((value) => {
|
|
583
|
+
try {
|
|
584
|
+
const record = JSON.parse(value) as { version?: unknown; origin?: unknown };
|
|
585
|
+
return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === input.origin;
|
|
586
|
+
} catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
: await backend.get(serverCredentialAccount(input.origin, input.trustIdentity)) !== null;
|
|
591
|
+
if (existing) throw new Error('local Borg server enrollment already exists');
|
|
592
|
+
}
|
|
593
|
+
const activeRecord: ServerCredentialRecord = {
|
|
477
594
|
origin: input.origin,
|
|
478
595
|
trustIdentity: input.trustIdentity,
|
|
479
596
|
credential: input.credential,
|
|
480
597
|
clientId: input.clientId,
|
|
481
598
|
serverCapabilities,
|
|
482
|
-
}
|
|
483
|
-
|
|
599
|
+
};
|
|
600
|
+
// Replace the active record and retire the pending tuple in one account-map
|
|
601
|
+
// write. A failed map write therefore preserves the old active + pending state.
|
|
602
|
+
if (backend.entries && backend.replaceAccounts) {
|
|
603
|
+
const accounts = await backend.entries();
|
|
604
|
+
const target = serverCredentialAccount(input.origin, input.trustIdentity);
|
|
605
|
+
const next = { ...accounts };
|
|
606
|
+
for (const [account, value] of Object.entries(accounts)) {
|
|
607
|
+
try {
|
|
608
|
+
const parsed = JSON.parse(value) as { version?: unknown; origin?: unknown };
|
|
609
|
+
if (parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === input.origin) {
|
|
610
|
+
delete next[account];
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
// Preserve unrelated malformed records; the backend's normal validation remains fail-closed.
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
next[target] = encodeServerCredentialRecord(activeRecord, serverCapabilities);
|
|
617
|
+
delete next[pendingAccount];
|
|
618
|
+
await backend.replaceAccounts(next);
|
|
619
|
+
} else {
|
|
620
|
+
await writeServerCredentialRecord(backend, activeRecord, input.allowReplacement === true);
|
|
621
|
+
await backend.delete(pendingAccount);
|
|
622
|
+
}
|
|
484
623
|
});
|
|
485
624
|
}
|
|
486
625
|
|
|
@@ -637,3 +776,23 @@ export async function clearServerCredential(origin: string, trustIdentity: strin
|
|
|
637
776
|
await backend.delete(pendingAccount);
|
|
638
777
|
});
|
|
639
778
|
}
|
|
779
|
+
|
|
780
|
+
/** Clear only the failed enrollment transaction for one origin/identity. */
|
|
781
|
+
export async function clearEnrollmentTransaction(origin: string, trustIdentity: string): Promise<void> {
|
|
782
|
+
validateServerCredentialBinding(origin, trustIdentity);
|
|
783
|
+
const backend = await getServerCredentialBackend();
|
|
784
|
+
const pendingAccount = serverPendingEnrollmentAccount(origin, trustIdentity);
|
|
785
|
+
const credentialAccount = serverCredentialAccount(origin, trustIdentity);
|
|
786
|
+
await withCredentialStoreLock(async () => {
|
|
787
|
+
if (backend.entries && backend.replaceAccounts) {
|
|
788
|
+
const accounts = await backend.entries();
|
|
789
|
+
const next = { ...accounts };
|
|
790
|
+
delete next[pendingAccount];
|
|
791
|
+
delete next[credentialAccount];
|
|
792
|
+
await backend.replaceAccounts(next);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
await backend.delete(pendingAccount);
|
|
796
|
+
await backend.delete(credentialAccount);
|
|
797
|
+
});
|
|
798
|
+
}
|
package/src/first-run-server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,10 +15,12 @@ const CLIENT_PACKAGE = 'borgmcp';
|
|
|
14
15
|
const SERVER_PACKAGE = 'borgmcp-server';
|
|
15
16
|
const SHARED_PACKAGE = 'borgmcp-shared';
|
|
16
17
|
const DEFAULT_CONNECT_COMMAND = 'borg assimilate --host <host>';
|
|
18
|
+
const CLIENT_ONBOARDING_ENV = 'BORG_CLIENT_ONBOARDING';
|
|
19
|
+
export const HINT_SUPPORTED_FROM = '0.8.1';
|
|
17
20
|
|
|
18
21
|
export type FirstRunServerInstallResult =
|
|
19
|
-
| { kind: 'present'; server: InstalledPackage }
|
|
20
|
-
| { kind: 'installed'; server: InstalledPackage }
|
|
22
|
+
| { kind: 'present'; server: InstalledPackage; suppressClientNextSteps?: boolean }
|
|
23
|
+
| { kind: 'installed'; server: InstalledPackage; suppressClientNextSteps?: boolean }
|
|
21
24
|
| { kind: 'declined' | 'non-interactive' | 'failed' };
|
|
22
25
|
|
|
23
26
|
export interface FirstRunServerInstallDeps {
|
|
@@ -32,7 +35,8 @@ export interface FirstRunServerInstallDeps {
|
|
|
32
35
|
version: string,
|
|
33
36
|
options?: { ignoreScripts?: boolean },
|
|
34
37
|
): Promise<void>;
|
|
35
|
-
|
|
38
|
+
runServerSetup(server: InstalledPackage, options?: { onboardingHint?: boolean }): Promise<number>;
|
|
39
|
+
confirm(message: string, defaultYes?: boolean): Promise<'yes' | 'no' | 'eof' | 'interrupted'>;
|
|
36
40
|
isTTY(): boolean;
|
|
37
41
|
stdout(text: string): void;
|
|
38
42
|
stderr(text: string): void;
|
|
@@ -84,6 +88,18 @@ export function buildDefaultFirstRunServerInstallDeps(): FirstRunServerInstallDe
|
|
|
84
88
|
publishedPackage: update.publishedPackage,
|
|
85
89
|
publishedVersions: update.publishedVersions,
|
|
86
90
|
installGlobal: update.installGlobal,
|
|
91
|
+
runServerSetup: (server, options = {}) => new Promise((resolve) => {
|
|
92
|
+
const childEnv = { ...process.env };
|
|
93
|
+
delete childEnv[CLIENT_ONBOARDING_ENV];
|
|
94
|
+
if (options.onboardingHint) childEnv[CLIENT_ONBOARDING_ENV] = '1';
|
|
95
|
+
const child = spawn(server.binPath, ['setup'], {
|
|
96
|
+
stdio: 'inherit',
|
|
97
|
+
shell: false,
|
|
98
|
+
env: childEnv,
|
|
99
|
+
});
|
|
100
|
+
child.once('error', () => resolve(1));
|
|
101
|
+
child.once('exit', (code) => resolve(code ?? 1));
|
|
102
|
+
}),
|
|
87
103
|
confirm: update.confirm,
|
|
88
104
|
isTTY: update.isTTY,
|
|
89
105
|
stdout: update.stdout,
|
|
@@ -100,6 +116,7 @@ export function buildDefaultFirstRunServerInstallDeps(): FirstRunServerInstallDe
|
|
|
100
116
|
export async function offerFirstRunServerInstall(
|
|
101
117
|
deps: FirstRunServerInstallDeps = buildDefaultFirstRunServerInstallDeps(),
|
|
102
118
|
connectCommand = DEFAULT_CONNECT_COMMAND,
|
|
119
|
+
options: { initializeServer?: boolean } = {},
|
|
103
120
|
): Promise<FirstRunServerInstallResult> {
|
|
104
121
|
let installed: InstalledPackage | null;
|
|
105
122
|
try {
|
|
@@ -112,7 +129,15 @@ export async function offerFirstRunServerInstall(
|
|
|
112
129
|
);
|
|
113
130
|
return { kind: 'failed' };
|
|
114
131
|
}
|
|
115
|
-
if (installed)
|
|
132
|
+
if (installed) {
|
|
133
|
+
return initializeServerIfRequested(
|
|
134
|
+
installed,
|
|
135
|
+
deps,
|
|
136
|
+
options.initializeServer,
|
|
137
|
+
'present',
|
|
138
|
+
shouldSuppressClientNextSteps(installed.version),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
116
141
|
|
|
117
142
|
if (!deps.isTTY()) {
|
|
118
143
|
deps.stderr(
|
|
@@ -140,9 +165,11 @@ export async function offerFirstRunServerInstall(
|
|
|
140
165
|
const command = exactInstallCommand(target.version);
|
|
141
166
|
const decision = await deps.confirm(
|
|
142
167
|
`No local ${SERVER_PACKAGE} installation was found. ` +
|
|
143
|
-
|
|
168
|
+
`Install ${SERVER_PACKAGE}@${target.version}, the local coordination server, ` +
|
|
169
|
+
`which uses ${SHARED_PACKAGE}@${target.sharedVersion}, now?\n` +
|
|
144
170
|
`Command: ${command}\n` +
|
|
145
|
-
|
|
171
|
+
`Install it now? [Y/n] `,
|
|
172
|
+
true,
|
|
146
173
|
);
|
|
147
174
|
if (decision !== 'yes') {
|
|
148
175
|
const reason = decision === 'interrupted'
|
|
@@ -172,7 +199,43 @@ export async function offerFirstRunServerInstall(
|
|
|
172
199
|
|
|
173
200
|
deps.stdout(
|
|
174
201
|
`Installed ${SERVER_PACKAGE}@${target.version} with ${SHARED_PACKAGE}@${target.sharedVersion}.\n` +
|
|
175
|
-
|
|
202
|
+
(options.initializeServer
|
|
203
|
+
? `The local server package is ready.\n`
|
|
204
|
+
: `Next, run \`borg server setup\`, then run \`borg server start\` in a terminal you keep open.\n`),
|
|
205
|
+
);
|
|
206
|
+
return initializeServerIfRequested(installed, deps, options.initializeServer, 'installed');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function initializeServerIfRequested(
|
|
210
|
+
server: InstalledPackage,
|
|
211
|
+
deps: FirstRunServerInstallDeps,
|
|
212
|
+
requested: boolean | undefined,
|
|
213
|
+
kind: 'present' | 'installed',
|
|
214
|
+
suppressClientNextSteps = false,
|
|
215
|
+
): Promise<FirstRunServerInstallResult> {
|
|
216
|
+
if (!requested) return { kind, server, ...(suppressClientNextSteps ? { suppressClientNextSteps: true } : {}) };
|
|
217
|
+
try {
|
|
218
|
+
const exitCode = await deps.runServerSetup(server, { onboardingHint: !suppressClientNextSteps });
|
|
219
|
+
if (exitCode === 0) {
|
|
220
|
+
return { kind, server, ...(suppressClientNextSteps ? { suppressClientNextSteps: true } : {}) };
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
deps.stderr(`Local server setup could not be completed. ${error instanceof Error ? error.message : String(error)}\n`);
|
|
224
|
+
}
|
|
225
|
+
deps.stderr(
|
|
226
|
+
'Run `borg server setup` to retry local server initialization, then run `borg server start`.\n',
|
|
176
227
|
);
|
|
177
|
-
return { kind: '
|
|
228
|
+
return { kind: 'failed' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function shouldSuppressClientNextSteps(version: unknown): boolean {
|
|
232
|
+
if (!isExactSemver(version)) return false;
|
|
233
|
+
const installedParts = version.split('.').slice(0, 3).map(Number);
|
|
234
|
+
const supportedParts = HINT_SUPPORTED_FROM.split('.').map(Number);
|
|
235
|
+
for (let index = 0; index < 3; index += 1) {
|
|
236
|
+
if (installedParts[index] !== supportedParts[index]) {
|
|
237
|
+
return installedParts[index] < supportedParts[index];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
178
241
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
decodeInvitationArtifact,
|
|
4
|
+
encodeInvitationArtifact,
|
|
5
|
+
getInvitationArtifactIntegrityInput,
|
|
6
|
+
type InvitationArtifact,
|
|
7
|
+
} from 'borgmcp-shared/protocol';
|
|
8
|
+
|
|
9
|
+
export const COMPATIBILITY_INVITATION_ERROR =
|
|
10
|
+
'This server did not present the certificate chain required for cross-machine enrollment. Update the Borg server on that machine and restart it, then run this command again. No invitation or credential was sent and no local trust or credential state was changed.';
|
|
11
|
+
|
|
12
|
+
export const TRUST_INVITATION_ERROR =
|
|
13
|
+
'Borg could not verify the server identity named by this invitation. No invitation or credential was sent and no local trust or credential state was changed. Ask the server operator for a current invitation, then retry.';
|
|
14
|
+
|
|
15
|
+
export const FORMAT_INVITATION_ERROR =
|
|
16
|
+
'This enrollment invitation is invalid or incomplete. No invitation or credential was sent and no local trust or credential state was changed. Ask the server operator for a new invitation, then retry.';
|
|
17
|
+
|
|
18
|
+
export const TRANSPORT_INVITATION_ERROR =
|
|
19
|
+
'Borg could not reach the server named by this invitation. No invitation or credential was sent and no local trust or credential state was changed. Check that the server is running and that this machine can reach the invitation endpoint, then retry.';
|
|
20
|
+
|
|
21
|
+
export const STORAGE_INVITATION_ERROR =
|
|
22
|
+
'Borg could not prepare local trust state for this invitation. No invitation or credential was sent and no local trust or credential state was changed. Check that Borg can write its private local state, then retry.';
|
|
23
|
+
|
|
24
|
+
export const RECOVERY_INVITATION_ERROR =
|
|
25
|
+
'Borg could not complete or undo the enrollment change. Prior local access may be unavailable. Run `borg recover-enrollment` to clear only this server enrollment transaction. The invitation used for this attempt has been consumed; after recovery, ask the server operator for a fresh invitation and retry.';
|
|
26
|
+
|
|
27
|
+
export class InvitationArtifactCompatibilityError extends Error {
|
|
28
|
+
constructor(message = COMPATIBILITY_INVITATION_ERROR) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'InvitationArtifactCompatibilityError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class InvitationArtifactLegacyError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super(
|
|
37
|
+
'This invitation uses an older format. Update the Borg server on that machine and restart it, then ask the server operator for a new invitation and retry. No invitation or credential was sent and no local trust or credential state was changed.',
|
|
38
|
+
);
|
|
39
|
+
this.name = 'InvitationArtifactLegacyError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class InvitationArtifactFormatError extends Error {
|
|
44
|
+
constructor(message = FORMAT_INVITATION_ERROR) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'InvitationArtifactFormatError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class InvitationArtifactEndpointMismatchError extends Error {
|
|
51
|
+
constructor(selectedEndpoint: string, invitationEndpoint: string) {
|
|
52
|
+
super(
|
|
53
|
+
`The enrollment invitation endpoint (${invitationEndpoint}) does not match the selected \`--host\` (${selectedEndpoint}). No invitation or credential was sent and no local trust or credential state was changed. Omit \`--host\` or select the exact invitation endpoint, then retry.`,
|
|
54
|
+
);
|
|
55
|
+
this.name = 'InvitationArtifactEndpointMismatchError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class InvitationArtifactStorageError extends Error {
|
|
60
|
+
constructor(message = STORAGE_INVITATION_ERROR) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'InvitationArtifactStorageError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class InvitationArtifactRecoveryError extends Error {
|
|
67
|
+
constructor(message = RECOVERY_INVITATION_ERROR) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'InvitationArtifactRecoveryError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class InvitationArtifactTransportError extends Error {
|
|
74
|
+
constructor(message = TRANSPORT_INVITATION_ERROR) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'InvitationArtifactTransportError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class InvitationArtifactTrustError extends Error {
|
|
81
|
+
constructor(message = TRUST_INVITATION_ERROR) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'InvitationArtifactTrustError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function invitationIntegrity(artifact: InvitationArtifact): string {
|
|
88
|
+
return createHmac('sha256', artifact.secret)
|
|
89
|
+
.update(getInvitationArtifactIntegrityInput({
|
|
90
|
+
...artifact,
|
|
91
|
+
integrity: 'p'.repeat(43),
|
|
92
|
+
}))
|
|
93
|
+
.digest('base64url');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function decodeAndVerifyInvitationArtifact(value: unknown): InvitationArtifact {
|
|
97
|
+
let artifact: InvitationArtifact;
|
|
98
|
+
try {
|
|
99
|
+
artifact = decodeInvitationArtifact(
|
|
100
|
+
typeof value === 'string' ? value : encodeInvitationArtifact(value as InvitationArtifact),
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : '';
|
|
104
|
+
if (typeof value === 'string' && value.length === 43) throw new InvitationArtifactLegacyError();
|
|
105
|
+
if (/legacy|unsupported/i.test(message)) throw new InvitationArtifactLegacyError();
|
|
106
|
+
if (/certificate chain/i.test(message)) throw new InvitationArtifactCompatibilityError();
|
|
107
|
+
throw new InvitationArtifactFormatError();
|
|
108
|
+
}
|
|
109
|
+
const expected = Buffer.from(invitationIntegrity(artifact), 'utf8');
|
|
110
|
+
const actual = Buffer.from(artifact.integrity, 'utf8');
|
|
111
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
112
|
+
if (typeof value === 'string' && value.length === 43) throw new InvitationArtifactLegacyError();
|
|
113
|
+
throw new InvitationArtifactFormatError();
|
|
114
|
+
}
|
|
115
|
+
return artifact;
|
|
116
|
+
}
|
package/src/launch-gate.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 2. the borg-regen SessionStart hook bin — exit-0 no-op;
|
|
12
12
|
* 3. the borg-clear-rewake clear-only hook bin — exit-0 no-op;
|
|
13
13
|
* 4. the borg-log-audit UserPromptSubmit hook bin — exit-0 no-op.
|
|
14
|
-
* Result:
|
|
14
|
+
* Result: direct agent launches keep the Borg surface inactive; `borg`
|
|
15
15
|
* launches get the full surface.
|
|
16
16
|
*
|
|
17
17
|
* Codex (V2/V2b probes): codex does NOT forward parent env to MCP
|
|
@@ -36,6 +36,8 @@ import { envToggleOn } from './auth-env.js';
|
|
|
36
36
|
import { debugLog } from './debug.js';
|
|
37
37
|
|
|
38
38
|
export const BORG_SESSION_ENV = 'BORG_SESSION';
|
|
39
|
+
/** Presence-based local opt-out for the plain-session launch reminder. */
|
|
40
|
+
export const BORG_LAUNCH_REMINDER_DISABLED_ENV = 'BORG_DISABLE_LAUNCH_REMINDER';
|
|
39
41
|
|
|
40
42
|
/** True when this process runs inside a borg-launched session. */
|
|
41
43
|
export function isBorgSession(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
@@ -43,16 +45,13 @@ export function isBorgSession(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* The non-silent per-tool notice a
|
|
47
|
-
*
|
|
48
|
-
* wrong with the cube — and points at the borg launch path.
|
|
48
|
+
* The non-silent per-tool notice a non-borg-launched session receives when
|
|
49
|
+
* invoking a borg_* tool. Outcome first; no CLI-specific or seat language.
|
|
49
50
|
*/
|
|
50
51
|
export function borgSessionToolNotice(toolName: string): string {
|
|
51
52
|
return (
|
|
52
|
-
`◼ ${toolName}
|
|
53
|
-
`
|
|
54
|
-
`re-launch from a terminal with \`borg\` (or \`borg assimilate\` to set up a seat first). ` +
|
|
55
|
-
`Power users can opt in manually by launching with BORG_SESSION=1.`
|
|
53
|
+
`◼ ${toolName} did not run: Borg tools are inactive because this session was not ` +
|
|
54
|
+
`launched with \`borg\`. Relaunch this session with \`borg\` to use cube coordination.`
|
|
56
55
|
);
|
|
57
56
|
}
|
|
58
57
|
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { AssimilateFlags } from './assimilate-cmd.js';
|
|
2
|
+
import { validateName } from './name-validator.js';
|
|
3
|
+
|
|
4
|
+
const ENROLLMENT_POSITIONAL_INPUT_ERROR =
|
|
5
|
+
'That argument was not accepted. If you meant a role name, use lowercase letters, digits, hyphens, or underscores, up to 48 characters. If you meant an enrollment invitation, it must be entered at the hidden prompt — re-run the same command without it.';
|
|
2
6
|
|
|
3
7
|
export interface ParseAssimilateResult {
|
|
4
8
|
ok: true;
|
|
@@ -18,6 +22,7 @@ export type ParseResult =
|
|
|
18
22
|
export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
|
|
19
23
|
let role: string | undefined;
|
|
20
24
|
const flags: AssimilateFlags = {};
|
|
25
|
+
const enrollmentRequested = rawArgs.includes('--enroll');
|
|
21
26
|
|
|
22
27
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
23
28
|
const arg = rawArgs[i];
|
|
@@ -105,8 +110,14 @@ export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
|
|
|
105
110
|
};
|
|
106
111
|
} else {
|
|
107
112
|
if (role !== undefined) {
|
|
113
|
+
if (enrollmentRequested) {
|
|
114
|
+
return { ok: false, error: ENROLLMENT_POSITIONAL_INPUT_ERROR };
|
|
115
|
+
}
|
|
108
116
|
return { ok: false, error: `unexpected extra argument: ${arg} (already have role "${role}")` };
|
|
109
117
|
}
|
|
118
|
+
if (enrollmentRequested && !validateName(arg).ok) {
|
|
119
|
+
return { ok: false, error: ENROLLMENT_POSITIONAL_INPUT_ERROR };
|
|
120
|
+
}
|
|
110
121
|
role = arg;
|
|
111
122
|
}
|
|
112
123
|
}
|
|
@@ -119,9 +130,5 @@ export function parseAssimilateArgs(rawArgs: string[]): ParseResult {
|
|
|
119
130
|
if (flags.template !== undefined && flags.noTemplate) {
|
|
120
131
|
return { ok: false, error: '--template and --no-template are mutually exclusive' };
|
|
121
132
|
}
|
|
122
|
-
if (flags.enroll && flags.server === undefined) {
|
|
123
|
-
return { ok: false, error: '--enroll requires --host <host>' };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
133
|
return { ok: true, role, flags };
|
|
127
134
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { normalizeServerEndpoint } from './server-endpoint.js';
|
|
2
|
+
import { clearEnrollmentTransaction, findPendingServerEnrollment } from './config.js';
|
|
3
|
+
import { clearBorgServerTrust } from './server-trust.js';
|
|
4
|
+
|
|
5
|
+
export interface RecoverEnrollmentFlags {
|
|
6
|
+
host?: string;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseRecoverEnrollmentArgs(argv: string[]):
|
|
11
|
+
{ ok: true; flags: RecoverEnrollmentFlags } | { ok: false; error: string } {
|
|
12
|
+
let host: string | undefined;
|
|
13
|
+
let yes = false;
|
|
14
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
+
const arg = argv[index];
|
|
16
|
+
if (arg === '--yes' || arg === '-y') { yes = true; continue; }
|
|
17
|
+
if (arg === '--host' || arg === '-h') {
|
|
18
|
+
const value = argv[++index];
|
|
19
|
+
if (!value) return { ok: false, error: 'missing value for --host' };
|
|
20
|
+
host = value;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (arg?.startsWith('--host=')) { host = arg.slice('--host='.length); continue; }
|
|
24
|
+
if (arg === '--help') return { ok: false, error: 'help' };
|
|
25
|
+
return { ok: false, error: `unexpected argument: ${arg}` };
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, flags: { ...(host === undefined ? {} : { host }), yes } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runRecoverEnrollment(
|
|
31
|
+
flags: RecoverEnrollmentFlags,
|
|
32
|
+
deps: { prompt: (message: string) => Promise<string>; stderr: (line: string) => void; stdout: (line: string) => void },
|
|
33
|
+
): Promise<number> {
|
|
34
|
+
const pending = await findPendingServerEnrollment();
|
|
35
|
+
if (!pending) {
|
|
36
|
+
deps.stderr('No recoverable Borg enrollment transaction was found. No state was changed.\n');
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
let origin = pending.origin;
|
|
40
|
+
if (flags.host !== undefined) {
|
|
41
|
+
try { origin = normalizeServerEndpoint(flags.host); } catch (error) {
|
|
42
|
+
deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
if (origin !== pending.origin) {
|
|
46
|
+
deps.stderr('The recovery host does not match the failed enrollment transaction. No state was changed.\n');
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!flags.yes) {
|
|
51
|
+
const answer = await deps.prompt(
|
|
52
|
+
`Clear only the failed enrollment for ${origin}? Other server enrollments and accounts will not be touched. [y/N]: `,
|
|
53
|
+
);
|
|
54
|
+
if (!/^y(?:es)?$/i.test(answer.trim())) {
|
|
55
|
+
deps.stderr('Enrollment recovery was not confirmed. No state was changed.\n');
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await clearEnrollmentTransaction(origin, pending.trustIdentity);
|
|
60
|
+
await clearBorgServerTrust(origin);
|
|
61
|
+
deps.stdout(`Cleared the failed enrollment transaction for ${origin}; other server enrollments and accounts were left unchanged.\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|