borgmcp-shared 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -56
- package/RELEASES.md +33 -0
- package/dist/conformance/adapter.d.ts +22 -0
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +169 -2
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/conformance/index.d.ts +22 -1
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +78 -1
- package/dist/conformance/index.js.map +1 -1
- package/dist/protocol/contract.d.ts +32 -1
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +212 -2
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/errors.d.ts +1 -0
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +1 -0
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/sse.d.ts +6 -2
- package/dist/protocol/sse.d.ts.map +1 -1
- package/dist/protocol/sse.js +8 -2
- package/dist/protocol/sse.js.map +1 -1
- package/dist/protocol/version.d.ts +1 -1
- package/dist/protocol/version.js +1 -1
- package/dist/templates.d.ts +1 -0
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +14 -3
- package/dist/templates.js.map +1 -1
- package/docs/compatibility.md +15 -4
- package/docs/enrollment.md +5 -4
- package/docs/releasing.md +4 -3
- package/package.json +2 -1
- package/src/conformance/adapter.ts +321 -0
- package/src/conformance/index.ts +102 -1
- package/src/protocol/contract.ts +260 -2
- package/src/protocol/errors.ts +1 -0
- package/src/protocol/sse.ts +17 -2
- package/src/protocol/version.ts +2 -2
- package/src/templates.ts +15 -3
package/src/protocol/contract.ts
CHANGED
|
@@ -13,12 +13,13 @@ import type {
|
|
|
13
13
|
} from './types.js';
|
|
14
14
|
|
|
15
15
|
export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
|
|
16
|
-
export const SHARED_PACKAGE_VERSION = '0.
|
|
16
|
+
export const SHARED_PACKAGE_VERSION = '0.8.0' as const;
|
|
17
17
|
|
|
18
18
|
export const HEALTH_PATH = '/healthz' as const;
|
|
19
19
|
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
20
20
|
export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
|
|
21
21
|
export const CUBES_PATH = '/api/cubes' as const;
|
|
22
|
+
export const CUBE_PATH = '/api/cubes/:cubeId' as const;
|
|
22
23
|
export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
|
|
23
24
|
export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
|
|
24
25
|
export const ATTACH_PATH = '/api/client/attach' as const;
|
|
@@ -29,6 +30,13 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
29
30
|
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
30
31
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
31
32
|
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
33
|
+
cube_delete: {
|
|
34
|
+
method: 'DELETE',
|
|
35
|
+
path: CUBE_PATH,
|
|
36
|
+
authenticated: true,
|
|
37
|
+
success_status: 200,
|
|
38
|
+
mutation: true,
|
|
39
|
+
},
|
|
32
40
|
repository_cube_resolve: {
|
|
33
41
|
method: 'POST',
|
|
34
42
|
path: REPOSITORY_CUBE_RESOLVE_PATH,
|
|
@@ -68,6 +76,7 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
68
76
|
session_revoked_status: 401,
|
|
69
77
|
session_rejected_status: 401,
|
|
70
78
|
cursor_expired_status: 410,
|
|
79
|
+
cube_deleted_status: 410,
|
|
71
80
|
drone_evicted_status: 410,
|
|
72
81
|
content_too_large_status: 413,
|
|
73
82
|
unsupported_protocol_status: 426,
|
|
@@ -116,6 +125,23 @@ export interface EnrollmentExchangeRequest {
|
|
|
116
125
|
client_name?: string;
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
export const INVITATION_ARTIFACT_VERSION = 2 as const;
|
|
129
|
+
export type InvitationAuthority = 'client' | 'owner';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The single opaque value transported between machines for enrollment. The
|
|
133
|
+
* integrity field is produced and verified by the issuing implementation; the
|
|
134
|
+
* shared codec preserves it as a bounded canonical field.
|
|
135
|
+
*/
|
|
136
|
+
export interface InvitationArtifact {
|
|
137
|
+
version: typeof INVITATION_ARTIFACT_VERSION;
|
|
138
|
+
endpoint: string;
|
|
139
|
+
ca_spki_sha256: string;
|
|
140
|
+
authority: InvitationAuthority;
|
|
141
|
+
secret: string;
|
|
142
|
+
integrity: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
export const SERVER_CAPABILITIES = ['create_cube'] as const;
|
|
120
146
|
export type ServerCapability = (typeof SERVER_CAPABILITIES)[number];
|
|
121
147
|
|
|
@@ -164,6 +190,13 @@ export interface CreateCubeResponse {
|
|
|
164
190
|
access: 'manage';
|
|
165
191
|
}
|
|
166
192
|
|
|
193
|
+
export type DeleteCubeRequest = Record<string, never>;
|
|
194
|
+
|
|
195
|
+
export interface DeleteCubeResponse {
|
|
196
|
+
cube_id: string;
|
|
197
|
+
deleted: true;
|
|
198
|
+
}
|
|
199
|
+
|
|
167
200
|
export interface ResolveRepositoryCubeRequest {
|
|
168
201
|
working_repo_name: string;
|
|
169
202
|
repository: CreateCubeRepository;
|
|
@@ -299,6 +332,207 @@ function opaqueToken(value: unknown, path: readonly (string | number)[]): string
|
|
|
299
332
|
return token;
|
|
300
333
|
}
|
|
301
334
|
|
|
335
|
+
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
336
|
+
const INVITATION_MAGIC = 'B2';
|
|
337
|
+
const INVITATION_LENGTH_HEX_DIGITS = 3;
|
|
338
|
+
|
|
339
|
+
function encodeBase64UrlAscii(value: string): string {
|
|
340
|
+
let output = '';
|
|
341
|
+
for (let index = 0; index < value.length; index += 3) {
|
|
342
|
+
const first = value.charCodeAt(index);
|
|
343
|
+
const hasSecond = index + 1 < value.length;
|
|
344
|
+
const hasThird = index + 2 < value.length;
|
|
345
|
+
const second = hasSecond ? value.charCodeAt(index + 1) : 0;
|
|
346
|
+
const third = hasThird ? value.charCodeAt(index + 2) : 0;
|
|
347
|
+
output += BASE64URL_ALPHABET[first >> 2];
|
|
348
|
+
output += BASE64URL_ALPHABET[((first & 0x03) << 4) | (second >> 4)];
|
|
349
|
+
if (hasSecond) output += BASE64URL_ALPHABET[((second & 0x0f) << 2) | (third >> 6)];
|
|
350
|
+
if (hasThird) output += BASE64URL_ALPHABET[third & 0x3f];
|
|
351
|
+
}
|
|
352
|
+
return output;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function decodeBase64UrlAscii(value: string, path: readonly (string | number)[]): string {
|
|
356
|
+
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
|
357
|
+
fail('Expected an unpadded base64url value.', path);
|
|
358
|
+
}
|
|
359
|
+
let output = '';
|
|
360
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
361
|
+
const first = BASE64URL_ALPHABET.indexOf(value[index]);
|
|
362
|
+
const second = BASE64URL_ALPHABET.indexOf(value[index + 1]);
|
|
363
|
+
const third = index + 2 < value.length ? BASE64URL_ALPHABET.indexOf(value[index + 2]) : 0;
|
|
364
|
+
const fourth = index + 3 < value.length ? BASE64URL_ALPHABET.indexOf(value[index + 3]) : 0;
|
|
365
|
+
if (first < 0 || second < 0 || (index + 2 < value.length && third < 0) ||
|
|
366
|
+
(index + 3 < value.length && fourth < 0)) {
|
|
367
|
+
fail('Expected an unpadded base64url value.', path);
|
|
368
|
+
}
|
|
369
|
+
const bytes = [
|
|
370
|
+
(first << 2) | (second >> 4),
|
|
371
|
+
((second & 0x0f) << 4) | (third >> 2),
|
|
372
|
+
((third & 0x03) << 6) | fourth,
|
|
373
|
+
];
|
|
374
|
+
const byteCount = Math.min(3, value.length - index - 1);
|
|
375
|
+
for (let byteIndex = 0; byteIndex < byteCount; byteIndex++) {
|
|
376
|
+
if (bytes[byteIndex] > 0x7f) fail('Invitation fields must contain ASCII bytes.', path);
|
|
377
|
+
output += String.fromCharCode(bytes[byteIndex]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (encodeBase64UrlAscii(output) !== value) {
|
|
381
|
+
fail('Expected canonical unpadded base64url encoding.', path);
|
|
382
|
+
}
|
|
383
|
+
return output;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function encodedBase64UrlLength(byteLength: number): number {
|
|
387
|
+
const remainder = byteLength % 3;
|
|
388
|
+
return Math.floor(byteLength / 3) * 4 + (remainder === 0 ? 0 : remainder + 1);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function invitationFieldLength(value: string, path: readonly (string | number)[]): string {
|
|
392
|
+
const length = value.length;
|
|
393
|
+
if (length > 0xfff) fail('Invitation field is too long.', path);
|
|
394
|
+
return length.toString(16).padStart(INVITATION_LENGTH_HEX_DIGITS, '0');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function decodeInvitationField(
|
|
398
|
+
payload: string,
|
|
399
|
+
cursor: { value: number },
|
|
400
|
+
path: readonly (string | number)[],
|
|
401
|
+
): string {
|
|
402
|
+
const lengthText = payload.slice(cursor.value, cursor.value + INVITATION_LENGTH_HEX_DIGITS);
|
|
403
|
+
if (!/^[0-9a-f]{3}$/.test(lengthText)) fail('Invitation field length is invalid.', path);
|
|
404
|
+
cursor.value += INVITATION_LENGTH_HEX_DIGITS;
|
|
405
|
+
const byteLength = Number.parseInt(lengthText, 16);
|
|
406
|
+
const encodedLength = encodedBase64UrlLength(byteLength);
|
|
407
|
+
const encoded = payload.slice(cursor.value, cursor.value + encodedLength);
|
|
408
|
+
if (encoded.length !== encodedLength) fail('Invitation field is truncated.', path);
|
|
409
|
+
cursor.value += encodedLength;
|
|
410
|
+
const decoded = decodeBase64UrlAscii(encoded, path);
|
|
411
|
+
if (decoded.length !== byteLength) fail('Invitation field length does not match its value.', path);
|
|
412
|
+
return decoded;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function canonicalInvitationEndpoint(value: unknown, path: readonly (string | number)[]): string {
|
|
416
|
+
const endpoint = boundedString(value, 1, 512, path);
|
|
417
|
+
type ParsedUrl = {
|
|
418
|
+
protocol: string;
|
|
419
|
+
origin: string;
|
|
420
|
+
hostname: string;
|
|
421
|
+
port: string;
|
|
422
|
+
pathname: string;
|
|
423
|
+
search: string;
|
|
424
|
+
hash: string;
|
|
425
|
+
username: string;
|
|
426
|
+
password: string;
|
|
427
|
+
};
|
|
428
|
+
const UrlParser = (globalThis as unknown as { URL?: new (value: string) => ParsedUrl }).URL;
|
|
429
|
+
if (UrlParser === undefined) fail('Invitation endpoint URL parsing is unavailable.', path);
|
|
430
|
+
let parsed: ParsedUrl;
|
|
431
|
+
try {
|
|
432
|
+
parsed = new UrlParser(endpoint);
|
|
433
|
+
} catch {
|
|
434
|
+
fail('Invitation endpoint must be a valid URL.', path);
|
|
435
|
+
}
|
|
436
|
+
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password ||
|
|
437
|
+
parsed.pathname !== '/' || parsed.search || parsed.hash || endpoint !== parsed.origin ||
|
|
438
|
+
(parsed.port !== '' && (Number.parseInt(parsed.port, 10) < 1 || Number.parseInt(parsed.port, 10) > 65_535))) {
|
|
439
|
+
fail('Invitation endpoint must be a canonical HTTPS origin.', path);
|
|
440
|
+
}
|
|
441
|
+
return endpoint;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function validateInvitationArtifact(value: unknown): InvitationArtifact {
|
|
445
|
+
const input = record(value);
|
|
446
|
+
exactKeys(input, ['version', 'endpoint', 'ca_spki_sha256', 'authority', 'secret', 'integrity'], [
|
|
447
|
+
'version',
|
|
448
|
+
'endpoint',
|
|
449
|
+
'ca_spki_sha256',
|
|
450
|
+
'authority',
|
|
451
|
+
'secret',
|
|
452
|
+
'integrity',
|
|
453
|
+
]);
|
|
454
|
+
if (input.version !== INVITATION_ARTIFACT_VERSION) {
|
|
455
|
+
fail('Unsupported invitation artifact version.', ['version']);
|
|
456
|
+
}
|
|
457
|
+
const endpoint = canonicalInvitationEndpoint(input.endpoint, ['endpoint']);
|
|
458
|
+
const caSpkiSha256 = boundedString(input.ca_spki_sha256, 64, 64, ['ca_spki_sha256']);
|
|
459
|
+
if (!/^[0-9a-f]{64}$/.test(caSpkiSha256)) {
|
|
460
|
+
fail('CA SPKI SHA-256 must be lowercase hexadecimal.', ['ca_spki_sha256']);
|
|
461
|
+
}
|
|
462
|
+
if (input.authority !== 'client' && input.authority !== 'owner') {
|
|
463
|
+
fail('Invitation authority is invalid.', ['authority']);
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
version: INVITATION_ARTIFACT_VERSION,
|
|
467
|
+
endpoint,
|
|
468
|
+
ca_spki_sha256: caSpkiSha256,
|
|
469
|
+
authority: input.authority,
|
|
470
|
+
secret: opaqueToken(input.secret, ['secret']),
|
|
471
|
+
integrity: opaqueToken(input.integrity, ['integrity']),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Return the canonical ASCII preimage for the artifact integrity binding.
|
|
477
|
+
* Implementations hash these exact bytes with their agreed secret algorithm;
|
|
478
|
+
* the shared package intentionally does not own a crypto runtime.
|
|
479
|
+
*/
|
|
480
|
+
export function getInvitationArtifactIntegrityInput(value: InvitationArtifact): string {
|
|
481
|
+
const artifact = validateInvitationArtifact(value);
|
|
482
|
+
const endpoint = encodeBase64UrlAscii(artifact.endpoint);
|
|
483
|
+
const secret = encodeBase64UrlAscii(artifact.secret);
|
|
484
|
+
return [
|
|
485
|
+
INVITATION_MAGIC,
|
|
486
|
+
invitationFieldLength(artifact.endpoint, ['endpoint']),
|
|
487
|
+
endpoint,
|
|
488
|
+
artifact.ca_spki_sha256,
|
|
489
|
+
artifact.authority === 'client' ? 'c' : 'o',
|
|
490
|
+
invitationFieldLength(artifact.secret, ['secret']),
|
|
491
|
+
secret,
|
|
492
|
+
].join('');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Encode an invitation artifact as one canonical, unpadded base64url token. */
|
|
496
|
+
export function encodeInvitationArtifact(value: InvitationArtifact): string {
|
|
497
|
+
const artifact = validateInvitationArtifact(value);
|
|
498
|
+
const payload = [
|
|
499
|
+
getInvitationArtifactIntegrityInput(artifact),
|
|
500
|
+
invitationFieldLength(artifact.integrity, ['integrity']),
|
|
501
|
+
encodeBase64UrlAscii(artifact.integrity),
|
|
502
|
+
].join('');
|
|
503
|
+
const token = encodeBase64UrlAscii(payload);
|
|
504
|
+
if (token.length < 43 || token.length > 1024) {
|
|
505
|
+
fail('Encoded invitation artifact exceeds the supported token bound.');
|
|
506
|
+
}
|
|
507
|
+
return token;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Decode and strictly validate a canonical invitation artifact token. */
|
|
511
|
+
export function decodeInvitationArtifact(value: unknown): InvitationArtifact {
|
|
512
|
+
const token = opaqueToken(value, ['invitation']);
|
|
513
|
+
const payload = decodeBase64UrlAscii(token, ['invitation']);
|
|
514
|
+
if (!payload.startsWith(INVITATION_MAGIC)) {
|
|
515
|
+
fail('Invitation uses an unsupported or legacy format.', ['invitation']);
|
|
516
|
+
}
|
|
517
|
+
const cursor = { value: INVITATION_MAGIC.length };
|
|
518
|
+
const endpoint = decodeInvitationField(payload, cursor, ['endpoint']);
|
|
519
|
+
const caSpkiSha256 = payload.slice(cursor.value, cursor.value + 64);
|
|
520
|
+
if (caSpkiSha256.length !== 64) fail('Invitation pin is truncated.', ['ca_spki_sha256']);
|
|
521
|
+
cursor.value += 64;
|
|
522
|
+
const authority = payload[cursor.value++];
|
|
523
|
+
const secret = decodeInvitationField(payload, cursor, ['secret']);
|
|
524
|
+
const integrity = decodeInvitationField(payload, cursor, ['integrity']);
|
|
525
|
+
if (cursor.value !== payload.length) fail('Invitation contains trailing fields.', ['invitation']);
|
|
526
|
+
return validateInvitationArtifact({
|
|
527
|
+
version: INVITATION_ARTIFACT_VERSION,
|
|
528
|
+
endpoint,
|
|
529
|
+
ca_spki_sha256: caSpkiSha256,
|
|
530
|
+
authority: authority === 'c' ? 'client' : authority === 'o' ? 'owner' : authority,
|
|
531
|
+
secret,
|
|
532
|
+
integrity,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
302
536
|
function decodeRequestId(value: unknown, path: readonly (string | number)[]): string {
|
|
303
537
|
const decoded = boundedString(value, 8, 128, path);
|
|
304
538
|
if (!/^[A-Za-z0-9._-]+$/.test(decoded)) {
|
|
@@ -327,7 +561,7 @@ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight
|
|
|
327
561
|
exactKeys(input, ['protocol_version'], ['protocol_version']);
|
|
328
562
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
329
563
|
throw new ProtocolContractError(
|
|
330
|
-
'This client requires protocol
|
|
564
|
+
'This client requires protocol v7. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
|
|
331
565
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
332
566
|
['protocol_version'],
|
|
333
567
|
);
|
|
@@ -596,6 +830,30 @@ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelo
|
|
|
596
830
|
return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
|
|
597
831
|
}
|
|
598
832
|
|
|
833
|
+
export function decodeDeleteCubeRequest(value: unknown): DeleteCubeRequest {
|
|
834
|
+
const input = record(value);
|
|
835
|
+
exactKeys(input, [], []);
|
|
836
|
+
return {};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function decodeDeleteCubeRequestEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeRequest> {
|
|
840
|
+
return decodeProtocolEnvelope(value, decodeDeleteCubeRequest);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function decodeDeleteCubeResponse(value: unknown): DeleteCubeResponse {
|
|
844
|
+
const input = record(value);
|
|
845
|
+
exactKeys(input, ['cube_id', 'deleted'], ['cube_id', 'deleted']);
|
|
846
|
+
if (input.deleted !== true) fail('Cube deletion result must be terminal.', ['deleted']);
|
|
847
|
+
return {
|
|
848
|
+
cube_id: decodeUuid(input.cube_id, ['cube_id']),
|
|
849
|
+
deleted: true,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export function decodeDeleteCubeResponseEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeResponse> {
|
|
854
|
+
return decodeProtocolEnvelope(value, decodeDeleteCubeResponse);
|
|
855
|
+
}
|
|
856
|
+
|
|
599
857
|
export function decodeResolveRepositoryCubeRequest(value: unknown): ResolveRepositoryCubeRequest {
|
|
600
858
|
const input = record(value);
|
|
601
859
|
exactKeys(input, ['working_repo_name', 'repository'], ['working_repo_name', 'repository']);
|
package/src/protocol/errors.ts
CHANGED
|
@@ -17,6 +17,7 @@ export enum ErrorCode {
|
|
|
17
17
|
CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
|
|
18
18
|
ROLE_IN_USE = 'ROLE_IN_USE',
|
|
19
19
|
ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
|
|
20
|
+
CUBE_DELETED = 'CUBE_DELETED',
|
|
20
21
|
DRONE_EVICTED = 'DRONE_EVICTED',
|
|
21
22
|
DRONE_FROZEN = 'DRONE_FROZEN',
|
|
22
23
|
UNSUPPORTED_PROTOCOL_VERSION = 'UNSUPPORTED_PROTOCOL_VERSION',
|
package/src/protocol/sse.ts
CHANGED
|
@@ -2,11 +2,13 @@ import type { EnrichedStreamEntry } from './types.js';
|
|
|
2
2
|
import {
|
|
3
3
|
ProtocolContractError,
|
|
4
4
|
decodeCanonicalTimestamp,
|
|
5
|
+
decodeProtocolErrorEnvelope,
|
|
5
6
|
decodeLogCursor,
|
|
6
7
|
decodeOpaqueIdentifier,
|
|
7
8
|
decodeUuid,
|
|
8
9
|
utf8ByteLength,
|
|
9
10
|
type LogCursor,
|
|
11
|
+
type ProtocolErrorEnvelope,
|
|
10
12
|
} from './contract.js';
|
|
11
13
|
|
|
12
14
|
export const SSE_LIMITS = {
|
|
@@ -31,6 +33,7 @@ export type StreamEvent =
|
|
|
31
33
|
actor_drone_id: string;
|
|
32
34
|
occurred_at: string;
|
|
33
35
|
}
|
|
36
|
+
| StreamErrorEvent
|
|
34
37
|
| { type: 'heartbeat'; at: string; broadcast_hwm: LogCursor | null }
|
|
35
38
|
| {
|
|
36
39
|
type: 'bookmark';
|
|
@@ -41,6 +44,12 @@ export type StreamEvent =
|
|
|
41
44
|
}
|
|
42
45
|
| { type: 'unknown'; event: string; raw_data: string };
|
|
43
46
|
|
|
47
|
+
/** A terminal stream failure; the server sends this frame once and then closes. */
|
|
48
|
+
export interface StreamErrorEvent {
|
|
49
|
+
type: 'error';
|
|
50
|
+
error: ProtocolErrorEnvelope;
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
function object(value: unknown): Record<string, unknown> {
|
|
45
54
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
46
55
|
throw new ProtocolContractError('SSE data must be a JSON object.');
|
|
@@ -124,7 +133,7 @@ export function decodeEnrichedStreamEntry(value: unknown): EnrichedStreamEntry {
|
|
|
124
133
|
|
|
125
134
|
export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>): string {
|
|
126
135
|
const lines = [`event: ${event.type}`];
|
|
127
|
-
let data:
|
|
136
|
+
let data: unknown;
|
|
128
137
|
if (event.type === 'log') {
|
|
129
138
|
const cursor = decodeLogCursor(event.cursor);
|
|
130
139
|
const entry = decodeEnrichedStreamEntry(event.entry);
|
|
@@ -142,6 +151,8 @@ export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>)
|
|
|
142
151
|
actor_drone_id: decodeUuid(event.actor_drone_id, ['actor_drone_id']),
|
|
143
152
|
occurred_at: decodeCanonicalTimestamp(event.occurred_at, ['occurred_at']),
|
|
144
153
|
};
|
|
154
|
+
} else if (event.type === 'error') {
|
|
155
|
+
data = decodeProtocolErrorEnvelope(event.error);
|
|
145
156
|
} else if (event.type === 'heartbeat') {
|
|
146
157
|
data = {
|
|
147
158
|
at: decodeCanonicalTimestamp(event.at, ['at']),
|
|
@@ -217,7 +228,7 @@ function decodeFrame(frame: string): StreamEvent {
|
|
|
217
228
|
if (rawDataBytes > SSE_LIMITS.data_bytes) {
|
|
218
229
|
throw new ProtocolContractError('SSE data exceeds the byte limit.');
|
|
219
230
|
}
|
|
220
|
-
if (!['log', 'ack', 'claim', 'heartbeat', 'bookmark'].includes(eventName)) {
|
|
231
|
+
if (!['log', 'ack', 'claim', 'error', 'heartbeat', 'bookmark'].includes(eventName)) {
|
|
221
232
|
if (rawDataBytes > SSE_LIMITS.unknown_data_bytes) {
|
|
222
233
|
throw new ProtocolContractError('Unknown SSE event data exceeds the byte limit.');
|
|
223
234
|
}
|
|
@@ -247,6 +258,10 @@ function decodeFrame(frame: string): StreamEvent {
|
|
|
247
258
|
throw new ProtocolContractError(`${eventName} SSE events must not carry a resume id.`);
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
if (eventName === 'error') {
|
|
262
|
+
return { type: 'error', error: decodeProtocolErrorEnvelope(parsed) };
|
|
263
|
+
}
|
|
264
|
+
|
|
250
265
|
if (eventName === 'ack' || eventName === 'claim') {
|
|
251
266
|
exactKeys(data, ['log_entry_id', 'actor_drone_id', 'occurred_at'], [
|
|
252
267
|
'log_entry_id',
|
package/src/protocol/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Current Borg coordination protocol generation. Clean-slate
|
|
2
|
-
export const PROTOCOL_VERSION = '
|
|
1
|
+
/** Current Borg coordination protocol generation. Clean-slate v7. */
|
|
2
|
+
export const PROTOCOL_VERSION = '7' as const;
|
|
3
3
|
|
|
4
4
|
export type ProtocolVersion = typeof PROTOCOL_VERSION;
|
package/src/templates.ts
CHANGED
|
@@ -136,6 +136,18 @@ Push discipline:
|
|
|
136
136
|
- Push only the assigned branch after verifying the staged paths and final diff.
|
|
137
137
|
- Do not force-push, rebase a shared branch, or publish from a local substitute artifact.`;
|
|
138
138
|
|
|
139
|
+
export const SAME_REPOSITORY_WORKFLOW_DISCIPLINE = `
|
|
140
|
+
|
|
141
|
+
Same-repository worktrees and handover:
|
|
142
|
+
- One seat uses one stable worktree, created once at assimilation under the standard worktree root and approved once by the operator. All seats for a repository are worktrees of the same clone family, sharing its object database and refs.
|
|
143
|
+
- Start each new work item by switching branches in that seat's worktree with \`git checkout -b <branch>\`; never create a new worktree or folder per work item.
|
|
144
|
+
- Create a branch only for a routed work item and announce its name in STARTING. One branch equals one work item and one owning seat; hand a branch to another seat only through an explicit log event.
|
|
145
|
+
- Use merge-only history: no rebases and no force-pushes, because another seat may have the branch checked out or fetched.
|
|
146
|
+
- Hand over a ref and exact commit SHA, never a filesystem path. Reviewers check out the SHA in their own worktree with \`git checkout --detach <SHA>\`; never read another seat's folder. Each review round binds to one exact SHA, and a new SHA restarts the gate sequence.
|
|
147
|
+
- With a hosted origin, push the branch at creation with \`git push -u origin <branch>\`; a branch is cube-visible and REVIEW-READY only after that push.
|
|
148
|
+
- With no hosted remote, the commit itself is the durable handover artifact because clone-family worktrees share refs; omit the push step. If push/fetch semantics are needed locally, use a local bare repository as the origin path.
|
|
149
|
+
- After every merge to the protected or main branch, broadcast the merge SHA. When an origin exists, include \`git fetch origin && git merge origin/main\` as the merge-only sync instruction.`;
|
|
150
|
+
|
|
139
151
|
export const UNIVERSAL_SAFETY_DISCIPLINES = [WAKE_PATH_MONITOR_DISCIPLINE];
|
|
140
152
|
|
|
141
153
|
export const ROLE_SCOPED_SAFETY_DISCIPLINES = [
|
|
@@ -162,7 +174,7 @@ const SOFTWARE_DEV_DIRECTIVE = `## Scope and coordination
|
|
|
162
174
|
- Reviewers assess the routed exact revision and do not create or expand work.
|
|
163
175
|
- Waiting is valid when no authorized action is available.
|
|
164
176
|
- Merge, deploy, publish, tag, release, credential, and live-operator actions require explicit authority.
|
|
165
|
-
- Keep cube-log signals concise. Put durable reasoning in the relevant issue, change, or existing maintained documentation only when it has an operational consumer
|
|
177
|
+
- Keep cube-log signals concise. Put durable reasoning in the relevant issue, change, or existing maintained documentation only when it has an operational consumer.${SAME_REPOSITORY_WORKFLOW_DISCIPLINE}`;
|
|
166
178
|
|
|
167
179
|
const SOFTWARE_DEV_TAXONOMY: MessageTaxonomy = [
|
|
168
180
|
{
|
|
@@ -469,7 +481,7 @@ const STARTER: Template = {
|
|
|
469
481
|
- Assignment, review, and completion do not authorize unrelated work or integration.
|
|
470
482
|
- ACK is receipt only; STARTING or substantive PROGRESS confirms activation.
|
|
471
483
|
- Findings outside scope are reported, not automatically fixed.
|
|
472
|
-
- Waiting is valid when no authorized action is available
|
|
484
|
+
- Waiting is valid when no authorized action is available.${SAME_REPOSITORY_WORKFLOW_DISCIPLINE}`,
|
|
473
485
|
message_taxonomy: STARTER_TAXONOMY,
|
|
474
486
|
roles: [
|
|
475
487
|
{
|
|
@@ -591,7 +603,7 @@ const LOCAL_MODEL_DIRECTIVE = `## Verification-cost workflow
|
|
|
591
603
|
- A fourth seat is optional: add a second Executor when throughput-bound, or a second capable Director as an independent review lens when correctness-bound. Never use a cheap model as a review lens.
|
|
592
604
|
- Waiting is valid only when no authorized action or active assigned work remains, or while a role is awaiting a named predecessor and has no independent action it can advance.
|
|
593
605
|
- Dispatch, packet echo, status, and answers are not completion. Each role continues its active item in the same turn until it posts a terminal signal from its own vocabulary.
|
|
594
|
-
- Merge, publish, deploy, tag, release, credential, and irreversible actions require explicit authority
|
|
606
|
+
- Merge, publish, deploy, tag, release, credential, and irreversible actions require explicit authority.${SAME_REPOSITORY_WORKFLOW_DISCIPLINE}`;
|
|
595
607
|
|
|
596
608
|
const LOCAL_MODEL_DIRECTOR = `You own authorized intent, priorities, decisions, and verification that requires careful reading. Never implement a change.
|
|
597
609
|
|