borgmcp-shared 0.5.1 → 0.6.1
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 +29 -8
- package/dist/conformance/adapter.d.ts +54 -1
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +265 -1
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/conformance/index.d.ts +9 -0
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +57 -1
- package/dist/conformance/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol/contract.d.ts +30 -2
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +62 -4
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/types.d.ts +16 -5
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/runtime-metadata.d.ts +28 -0
- package/dist/runtime-metadata.d.ts.map +1 -0
- package/dist/runtime-metadata.js +251 -0
- package/dist/runtime-metadata.js.map +1 -0
- package/docs/compatibility.md +28 -0
- package/docs/enrollment.md +12 -4
- package/docs/releasing.md +15 -5
- package/package.json +5 -1
- package/src/conformance/adapter.ts +457 -0
- package/src/conformance/index.ts +66 -1
- package/src/index.ts +1 -0
- package/src/protocol/contract.ts +110 -5
- package/src/protocol/types.ts +18 -5
- package/src/runtime-metadata.ts +304 -0
package/src/protocol/contract.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import { ErrorCode } from './errors.js';
|
|
2
2
|
import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
|
|
3
|
+
import {
|
|
4
|
+
RuntimeMetadataValidationError,
|
|
5
|
+
validateRuntimeMetadata,
|
|
6
|
+
validateRuntimeMetadataPatch,
|
|
7
|
+
validateRuntimeMetadataReportState,
|
|
8
|
+
} from '../runtime-metadata.js';
|
|
9
|
+
import type {
|
|
10
|
+
DroneRuntimeMetadata,
|
|
11
|
+
DroneRuntimeMetadataPatch,
|
|
12
|
+
} from './types.js';
|
|
3
13
|
|
|
4
14
|
export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
|
|
5
|
-
export const SHARED_PACKAGE_VERSION = '0.
|
|
15
|
+
export const SHARED_PACKAGE_VERSION = '0.6.1' as const;
|
|
6
16
|
|
|
7
17
|
export const HEALTH_PATH = '/healthz' as const;
|
|
8
18
|
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
9
19
|
export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
|
|
10
20
|
export const CUBES_PATH = '/api/cubes' as const;
|
|
21
|
+
export const ATTACH_PATH = '/api/client/attach' as const;
|
|
22
|
+
export const SELF_RUNTIME_METADATA_PATH = '/api/cubes/:cubeId/drones/self/metadata' as const;
|
|
11
23
|
|
|
12
24
|
export const PROTOCOL_HTTP_CONTRACT = {
|
|
13
25
|
health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
|
|
14
26
|
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
15
27
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
16
28
|
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
29
|
+
attach: { method: 'POST', path: ATTACH_PATH, authenticated: true, success_status: 200 },
|
|
17
30
|
drone_reassign: {
|
|
18
31
|
method: 'PATCH',
|
|
19
32
|
path: '/api/cubes/:cubeId/drones/:droneId',
|
|
@@ -26,6 +39,12 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
26
39
|
authenticated: true,
|
|
27
40
|
success_status: 200,
|
|
28
41
|
},
|
|
42
|
+
drone_self_metadata: {
|
|
43
|
+
method: 'PATCH',
|
|
44
|
+
path: SELF_RUNTIME_METADATA_PATH,
|
|
45
|
+
authenticated: 'drone-session',
|
|
46
|
+
success_status: 200,
|
|
47
|
+
},
|
|
29
48
|
auth_missing_status: 401,
|
|
30
49
|
auth_invalid_status: 401,
|
|
31
50
|
auth_expired_status: 401,
|
|
@@ -607,15 +626,86 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
|
|
|
607
626
|
return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
|
|
608
627
|
}
|
|
609
628
|
|
|
610
|
-
|
|
629
|
+
function metadataValidation<T>(fn: () => T): T {
|
|
630
|
+
try {
|
|
631
|
+
return fn();
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error instanceof RuntimeMetadataValidationError) {
|
|
634
|
+
fail(`${error.field}: ${error.reason}`, [error.field]);
|
|
635
|
+
}
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
611
639
|
|
|
612
|
-
|
|
640
|
+
/** Decode a complete runtime report. When present on attach, all four keys are required. */
|
|
641
|
+
export function decodeDroneRuntimeMetadata(value: unknown): DroneRuntimeMetadata {
|
|
642
|
+
return metadataValidation(() => validateRuntimeMetadata(value));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Decode an atomic self-heal patch. Omitted means unchanged; null means clear. */
|
|
646
|
+
export function decodeDroneRuntimeMetadataPatch(value: unknown): DroneRuntimeMetadataPatch {
|
|
647
|
+
return metadataValidation(() => validateRuntimeMetadataPatch(value));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Decode the flat runtime state carried by Drone objects in roster and regen responses. */
|
|
651
|
+
export function decodeDroneRuntimeMetadataState(value: unknown): UpdateDroneRuntimeMetadataResponse {
|
|
652
|
+
const input = record(value);
|
|
653
|
+
return metadataValidation(() => validateRuntimeMetadataReportState({
|
|
654
|
+
agent_kind: input.agent_kind,
|
|
655
|
+
reported_model: input.reported_model,
|
|
656
|
+
working_repo_name: input.working_repo_name,
|
|
657
|
+
working_repo_origin: input.working_repo_origin,
|
|
658
|
+
}, input.runtime_metadata_reported));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Decode the nested runtime state carried by the own-seat identity response. */
|
|
662
|
+
export function decodeWhoAmIRuntimeMetadataState(value: unknown): UpdateDroneRuntimeMetadataResponse {
|
|
663
|
+
const input = record(value);
|
|
664
|
+
return metadataValidation(() => validateRuntimeMetadataReportState(
|
|
665
|
+
input.runtime_metadata,
|
|
666
|
+
input.runtime_metadata_reported,
|
|
667
|
+
));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface UpdateDroneRuntimeMetadataResponse {
|
|
671
|
+
runtime_metadata: DroneRuntimeMetadata;
|
|
672
|
+
runtime_metadata_reported: boolean;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function decodeUpdateDroneRuntimeMetadataResponse(
|
|
676
|
+
value: unknown,
|
|
677
|
+
): UpdateDroneRuntimeMetadataResponse {
|
|
678
|
+
const input = record(value);
|
|
679
|
+
exactKeys(input, ['runtime_metadata', 'runtime_metadata_reported'], [
|
|
680
|
+
'runtime_metadata',
|
|
681
|
+
'runtime_metadata_reported',
|
|
682
|
+
]);
|
|
683
|
+
return metadataValidation(() => validateRuntimeMetadataReportState(
|
|
684
|
+
input.runtime_metadata,
|
|
685
|
+
input.runtime_metadata_reported,
|
|
686
|
+
));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function decodeUpdateDroneRuntimeMetadataRequestEnvelope(
|
|
690
|
+
value: unknown,
|
|
691
|
+
): ProtocolEnvelope<DroneRuntimeMetadataPatch> {
|
|
692
|
+
return decodeProtocolEnvelope(value, decodeDroneRuntimeMetadataPatch);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function decodeUpdateDroneRuntimeMetadataResponseEnvelope(
|
|
696
|
+
value: unknown,
|
|
697
|
+
): ProtocolEnvelope<UpdateDroneRuntimeMetadataResponse> {
|
|
698
|
+
return decodeProtocolEnvelope(value, decodeUpdateDroneRuntimeMetadataResponse);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ── v3 clean-slate wire types ──────────────────────────────────────────────
|
|
613
702
|
|
|
614
703
|
export interface AttachRequest {
|
|
615
704
|
cube_id: string;
|
|
616
705
|
role_id: string;
|
|
617
706
|
session_credential: string;
|
|
618
707
|
prior_drone_id?: string;
|
|
708
|
+
runtime_metadata?: DroneRuntimeMetadata;
|
|
619
709
|
}
|
|
620
710
|
|
|
621
711
|
export interface AttachCube {
|
|
@@ -635,6 +725,8 @@ export interface AttachRole {
|
|
|
635
725
|
export interface AttachDrone {
|
|
636
726
|
id: string;
|
|
637
727
|
label: string;
|
|
728
|
+
runtime_metadata: DroneRuntimeMetadata;
|
|
729
|
+
runtime_metadata_reported: boolean;
|
|
638
730
|
}
|
|
639
731
|
|
|
640
732
|
export interface AttachSession {
|
|
@@ -682,10 +774,20 @@ function decodeAttachRole(value: unknown, path: readonly (string | number)[]): A
|
|
|
682
774
|
|
|
683
775
|
function decodeAttachDrone(value: unknown, path: readonly (string | number)[]): AttachDrone {
|
|
684
776
|
const input = record(value, path);
|
|
685
|
-
exactKeys(
|
|
777
|
+
exactKeys(
|
|
778
|
+
input,
|
|
779
|
+
['id', 'label', 'runtime_metadata', 'runtime_metadata_reported'],
|
|
780
|
+
['id', 'label', 'runtime_metadata', 'runtime_metadata_reported'],
|
|
781
|
+
path,
|
|
782
|
+
);
|
|
783
|
+
const state = metadataValidation(() => validateRuntimeMetadataReportState(
|
|
784
|
+
input.runtime_metadata,
|
|
785
|
+
input.runtime_metadata_reported,
|
|
786
|
+
));
|
|
686
787
|
return {
|
|
687
788
|
id: decodeUuid(input.id, [...path, 'id']),
|
|
688
789
|
label: boundedString(input.label, 1, 128, [...path, 'label']),
|
|
790
|
+
...state,
|
|
689
791
|
};
|
|
690
792
|
}
|
|
691
793
|
|
|
@@ -703,7 +805,7 @@ function decodeAttachSession(value: unknown, path: readonly (string | number)[])
|
|
|
703
805
|
*/
|
|
704
806
|
export function decodeAttachRequest(value: unknown): AttachRequest {
|
|
705
807
|
const input = record(value);
|
|
706
|
-
exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id'], [
|
|
808
|
+
exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id', 'runtime_metadata'], [
|
|
707
809
|
'cube_id',
|
|
708
810
|
'role_id',
|
|
709
811
|
'session_credential',
|
|
@@ -716,6 +818,9 @@ export function decodeAttachRequest(value: unknown): AttachRequest {
|
|
|
716
818
|
if (input.prior_drone_id !== undefined) {
|
|
717
819
|
result.prior_drone_id = decodeUuid(input.prior_drone_id, ['prior_drone_id']);
|
|
718
820
|
}
|
|
821
|
+
if (input.runtime_metadata !== undefined) {
|
|
822
|
+
result.runtime_metadata = decodeDroneRuntimeMetadata(input.runtime_metadata);
|
|
823
|
+
}
|
|
719
824
|
return result;
|
|
720
825
|
}
|
|
721
826
|
|
package/src/protocol/types.ts
CHANGED
|
@@ -14,6 +14,20 @@ export type WakePathAlertClass =
|
|
|
14
14
|
| 'systemic-wake-path-deaf'
|
|
15
15
|
| 'independent';
|
|
16
16
|
|
|
17
|
+
export interface DroneRuntimeMetadata {
|
|
18
|
+
agent_kind: AgentKind | null;
|
|
19
|
+
reported_model: string | null;
|
|
20
|
+
working_repo_name: string | null;
|
|
21
|
+
working_repo_origin: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DroneRuntimeMetadataPatch {
|
|
25
|
+
agent_kind?: AgentKind | null;
|
|
26
|
+
reported_model?: string | null;
|
|
27
|
+
working_repo_name?: string | null;
|
|
28
|
+
working_repo_origin?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export interface Cube {
|
|
18
32
|
id: string;
|
|
19
33
|
owner_id: string;
|
|
@@ -45,7 +59,7 @@ export interface Role {
|
|
|
45
59
|
|
|
46
60
|
export type PublicRole = Omit<Role, 'detailed_description' | 'detailed_description_hash'>;
|
|
47
61
|
|
|
48
|
-
export interface Drone {
|
|
62
|
+
export interface Drone extends DroneRuntimeMetadata {
|
|
49
63
|
id: string;
|
|
50
64
|
cube_id: string;
|
|
51
65
|
role_id: string;
|
|
@@ -61,10 +75,7 @@ export interface Drone {
|
|
|
61
75
|
wake_path_client_sse_connected?: boolean | null;
|
|
62
76
|
wake_path_client_monitor_armed?: boolean | null;
|
|
63
77
|
wake_path_alert_class?: WakePathAlertClass | null;
|
|
64
|
-
|
|
65
|
-
reported_model?: string | null;
|
|
66
|
-
working_repo_name?: string | null;
|
|
67
|
-
working_repo_origin?: string | null;
|
|
78
|
+
runtime_metadata_reported: boolean;
|
|
68
79
|
evicted_at?: null;
|
|
69
80
|
created_at: string;
|
|
70
81
|
seen_since?: boolean;
|
|
@@ -122,6 +133,8 @@ export interface WhoAmIResponse {
|
|
|
122
133
|
drone_label: string;
|
|
123
134
|
role_id: string;
|
|
124
135
|
role_name: string;
|
|
136
|
+
runtime_metadata: DroneRuntimeMetadata;
|
|
137
|
+
runtime_metadata_reported: boolean;
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
export interface RosterResponse {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DroneRuntimeMetadata,
|
|
3
|
+
DroneRuntimeMetadataPatch,
|
|
4
|
+
} from './protocol/types.js';
|
|
5
|
+
|
|
6
|
+
export const RUNTIME_METADATA_LIMITS = {
|
|
7
|
+
reported_model_bytes: 160,
|
|
8
|
+
repo_segment_bytes: 100,
|
|
9
|
+
repo_name_bytes: 201,
|
|
10
|
+
repo_origin_bytes: 512,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type RuntimeMetadataField =
|
|
14
|
+
| 'runtime_metadata'
|
|
15
|
+
| 'runtime_metadata_reported'
|
|
16
|
+
| 'agent_kind'
|
|
17
|
+
| 'reported_model'
|
|
18
|
+
| 'working_repo_name'
|
|
19
|
+
| 'working_repo_origin';
|
|
20
|
+
|
|
21
|
+
export class RuntimeMetadataValidationError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
public readonly field: RuntimeMetadataField,
|
|
24
|
+
public readonly reason: string,
|
|
25
|
+
) {
|
|
26
|
+
super(`${field}: ${reason}`);
|
|
27
|
+
this.name = 'RuntimeMetadataValidationError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/@:+-]*$/;
|
|
32
|
+
const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
|
|
33
|
+
const UNSAFE_UNICODE_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]|\p{Bidi_Control}/u;
|
|
34
|
+
|
|
35
|
+
function byteLength(value: string): number {
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
for (let index = 0; index < value.length; index++) {
|
|
38
|
+
const code = value.charCodeAt(index);
|
|
39
|
+
if (code <= 0x7f) bytes += 1;
|
|
40
|
+
else if (code <= 0x7ff) bytes += 2;
|
|
41
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
42
|
+
bytes += 4;
|
|
43
|
+
index++;
|
|
44
|
+
} else bytes += 3;
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasMalformedUnicode(value: string): boolean {
|
|
50
|
+
for (let index = 0; index < value.length; index++) {
|
|
51
|
+
const code = value.charCodeAt(index);
|
|
52
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
53
|
+
const next = value.charCodeAt(index + 1);
|
|
54
|
+
if (next < 0xdc00 || next > 0xdfff) return true;
|
|
55
|
+
index++;
|
|
56
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function invalid(field: RuntimeMetadataField, reason: string): never {
|
|
64
|
+
throw new RuntimeMetadataValidationError(field, reason);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const METADATA_KEYS = [
|
|
68
|
+
'agent_kind',
|
|
69
|
+
'reported_model',
|
|
70
|
+
'working_repo_name',
|
|
71
|
+
'working_repo_origin',
|
|
72
|
+
] as const;
|
|
73
|
+
|
|
74
|
+
function metadataRecord(value: unknown): Record<string, unknown> {
|
|
75
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
76
|
+
invalid('runtime_metadata', 'must be an object');
|
|
77
|
+
}
|
|
78
|
+
const prototype = Object.getPrototypeOf(value);
|
|
79
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
80
|
+
invalid('runtime_metadata', 'must be a plain object');
|
|
81
|
+
}
|
|
82
|
+
const keys = Reflect.ownKeys(value);
|
|
83
|
+
if (keys.some((key) => typeof key !== 'string' || !METADATA_KEYS.includes(key as typeof METADATA_KEYS[number]))) {
|
|
84
|
+
invalid('runtime_metadata', 'contains an unsupported field');
|
|
85
|
+
}
|
|
86
|
+
if (keys.some((key) => {
|
|
87
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
88
|
+
return descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, 'value');
|
|
89
|
+
})) {
|
|
90
|
+
invalid('runtime_metadata', 'fields must be plain data values');
|
|
91
|
+
}
|
|
92
|
+
return value as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function agentKind(value: unknown): DroneRuntimeMetadata['agent_kind'] {
|
|
96
|
+
if (value === null || value === 'claude' || value === 'codex' || value === 'opencode') {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
invalid('agent_kind', 'must be claude, codex, opencode, or null');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function nullableString(value: unknown, field: RuntimeMetadataField): string | null {
|
|
103
|
+
if (value === null || typeof value === 'string') return value;
|
|
104
|
+
invalid(field, 'must be a string or null');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateReportedModel(value: string): string {
|
|
108
|
+
const bytes = byteLength(value);
|
|
109
|
+
if (bytes < 1 || bytes > RUNTIME_METADATA_LIMITS.reported_model_bytes) {
|
|
110
|
+
invalid('reported_model', 'must be 1-160 UTF-8 bytes');
|
|
111
|
+
}
|
|
112
|
+
if (hasMalformedUnicode(value) || UNSAFE_UNICODE_PATTERN.test(value) || !MODEL_PATTERN.test(value)) {
|
|
113
|
+
invalid('reported_model', 'must be a printable model identifier');
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function validateWorkingRepoName(value: string): string {
|
|
119
|
+
if (byteLength(value) > RUNTIME_METADATA_LIMITS.repo_name_bytes) {
|
|
120
|
+
invalid('working_repo_name', 'must be at most 201 bytes');
|
|
121
|
+
}
|
|
122
|
+
const segments = value.split('/');
|
|
123
|
+
if (
|
|
124
|
+
segments.length !== 2 ||
|
|
125
|
+
segments.some((segment) =>
|
|
126
|
+
segment === '.' ||
|
|
127
|
+
segment === '..' ||
|
|
128
|
+
byteLength(segment) > RUNTIME_METADATA_LIMITS.repo_segment_bytes ||
|
|
129
|
+
!REPO_SEGMENT_PATTERN.test(segment)
|
|
130
|
+
)
|
|
131
|
+
) {
|
|
132
|
+
invalid('working_repo_name', 'must be a canonical owner/repository name');
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validatePublicHost(hostname: string): string {
|
|
138
|
+
const host = hostname.toLowerCase();
|
|
139
|
+
if (
|
|
140
|
+
host.length > 253 ||
|
|
141
|
+
!host.includes('.') ||
|
|
142
|
+
host.includes(':') ||
|
|
143
|
+
/^(?:0x[0-9a-f]+|\d+)(?:\.(?:0x[0-9a-f]+|\d+)){1,3}$/.test(host) ||
|
|
144
|
+
/(?:^|\.)(?:localhost|local|internal|lan)$/.test(host)
|
|
145
|
+
) {
|
|
146
|
+
invalid('working_repo_origin', 'must use a public DNS host');
|
|
147
|
+
}
|
|
148
|
+
const labels = host.split('.');
|
|
149
|
+
if (labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
|
|
150
|
+
invalid('working_repo_origin', 'must use a public DNS host');
|
|
151
|
+
}
|
|
152
|
+
return host;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function originParts(value: string): { host: string; path: string } {
|
|
156
|
+
if (
|
|
157
|
+
byteLength(value) > RUNTIME_METADATA_LIMITS.repo_origin_bytes ||
|
|
158
|
+
value.includes('%') ||
|
|
159
|
+
hasMalformedUnicode(value) ||
|
|
160
|
+
UNSAFE_UNICODE_PATTERN.test(value) ||
|
|
161
|
+
/[\u0080-\uffff]/u.test(value)
|
|
162
|
+
) {
|
|
163
|
+
invalid('working_repo_origin', 'must be a safe canonical repository URL');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const scp = /^git@([^/:]+):(.+)$/.exec(value);
|
|
167
|
+
if (scp) return { host: validatePublicHost(scp[1]), path: scp[2] };
|
|
168
|
+
|
|
169
|
+
const hierarchical = /^(https|ssh):\/\/([^/]+)\/(.+)$/.exec(value);
|
|
170
|
+
if (!hierarchical) {
|
|
171
|
+
invalid('working_repo_origin', 'must be an HTTPS or Git SSH repository URL');
|
|
172
|
+
}
|
|
173
|
+
const [, scheme, authority, path] = hierarchical;
|
|
174
|
+
if (path.includes('?') || path.includes('#')) {
|
|
175
|
+
invalid('working_repo_origin', 'must not include a query or fragment');
|
|
176
|
+
}
|
|
177
|
+
if (scheme === 'https') {
|
|
178
|
+
if (authority.includes('@')) {
|
|
179
|
+
invalid('working_repo_origin', 'must not include credentials or a non-default port');
|
|
180
|
+
}
|
|
181
|
+
const hostMatch = /^([^:]+)(?::443)?$/.exec(authority);
|
|
182
|
+
if (!hostMatch) {
|
|
183
|
+
invalid('working_repo_origin', 'must not include credentials or a non-default port');
|
|
184
|
+
}
|
|
185
|
+
return { host: validatePublicHost(hostMatch[1]), path };
|
|
186
|
+
}
|
|
187
|
+
const sshMatch = /^git@([^:]+)(?::22)?$/.exec(authority);
|
|
188
|
+
if (!sshMatch) {
|
|
189
|
+
invalid('working_repo_origin', 'SSH repository URLs require the literal git user');
|
|
190
|
+
}
|
|
191
|
+
return { host: validatePublicHost(sshMatch[1]), path };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface CanonicalRepositoryIdentity {
|
|
195
|
+
working_repo_name: string;
|
|
196
|
+
working_repo_origin: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function canonicalizeRepositoryIdentity(
|
|
200
|
+
inputOrigin: string,
|
|
201
|
+
expectedName?: string,
|
|
202
|
+
): CanonicalRepositoryIdentity {
|
|
203
|
+
const { host, path: rawPath } = originParts(inputOrigin);
|
|
204
|
+
const path = rawPath.endsWith('.git') ? rawPath.slice(0, -4) : rawPath;
|
|
205
|
+
if (path.includes('\\') || path.startsWith('/') || path.endsWith('/')) {
|
|
206
|
+
invalid('working_repo_origin', 'must identify exactly one owner/repository path');
|
|
207
|
+
}
|
|
208
|
+
const name = validateWorkingRepoName(path);
|
|
209
|
+
if (expectedName !== undefined && validateWorkingRepoName(expectedName) !== name) {
|
|
210
|
+
invalid('working_repo_name', 'must match the canonical repository origin');
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
working_repo_name: name,
|
|
214
|
+
working_repo_origin: `https://${host}/${name}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function validateRuntimeMetadata(value: unknown): DroneRuntimeMetadata {
|
|
219
|
+
const input = metadataRecord(value);
|
|
220
|
+
if (METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(input, key))) {
|
|
221
|
+
invalid('runtime_metadata', 'must contain all four metadata fields');
|
|
222
|
+
}
|
|
223
|
+
const metadata: DroneRuntimeMetadata = {
|
|
224
|
+
agent_kind: agentKind(input.agent_kind),
|
|
225
|
+
reported_model: nullableString(input.reported_model, 'reported_model'),
|
|
226
|
+
working_repo_name: nullableString(input.working_repo_name, 'working_repo_name'),
|
|
227
|
+
working_repo_origin: nullableString(input.working_repo_origin, 'working_repo_origin'),
|
|
228
|
+
};
|
|
229
|
+
if (metadata.reported_model !== null) validateReportedModel(metadata.reported_model);
|
|
230
|
+
if ((metadata.working_repo_name === null) !== (metadata.working_repo_origin === null)) {
|
|
231
|
+
invalid('working_repo_name', 'repository name and origin must be set or cleared together');
|
|
232
|
+
}
|
|
233
|
+
if (metadata.working_repo_name !== null && metadata.working_repo_origin !== null) {
|
|
234
|
+
const canonical = canonicalizeRepositoryIdentity(
|
|
235
|
+
metadata.working_repo_origin,
|
|
236
|
+
metadata.working_repo_name,
|
|
237
|
+
);
|
|
238
|
+
return { ...metadata, ...canonical };
|
|
239
|
+
}
|
|
240
|
+
return { ...metadata };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function validateRuntimeMetadataPatch(
|
|
244
|
+
value: unknown,
|
|
245
|
+
): DroneRuntimeMetadataPatch {
|
|
246
|
+
const input = metadataRecord(value);
|
|
247
|
+
if (Reflect.ownKeys(input).length === 0) invalid('runtime_metadata', 'patch must not be empty');
|
|
248
|
+
const patch: DroneRuntimeMetadataPatch = {};
|
|
249
|
+
if (Object.prototype.hasOwnProperty.call(input, 'agent_kind')) {
|
|
250
|
+
patch.agent_kind = agentKind(input.agent_kind);
|
|
251
|
+
}
|
|
252
|
+
if (Object.prototype.hasOwnProperty.call(input, 'reported_model')) {
|
|
253
|
+
patch.reported_model = nullableString(input.reported_model, 'reported_model');
|
|
254
|
+
}
|
|
255
|
+
if (Object.prototype.hasOwnProperty.call(input, 'working_repo_name')) {
|
|
256
|
+
patch.working_repo_name = nullableString(input.working_repo_name, 'working_repo_name');
|
|
257
|
+
}
|
|
258
|
+
if (Object.prototype.hasOwnProperty.call(input, 'working_repo_origin')) {
|
|
259
|
+
patch.working_repo_origin = nullableString(input.working_repo_origin, 'working_repo_origin');
|
|
260
|
+
}
|
|
261
|
+
if (patch.reported_model !== undefined && patch.reported_model !== null) {
|
|
262
|
+
validateReportedModel(patch.reported_model);
|
|
263
|
+
}
|
|
264
|
+
const hasName = patch.working_repo_name !== undefined;
|
|
265
|
+
const hasOrigin = patch.working_repo_origin !== undefined;
|
|
266
|
+
if (hasName !== hasOrigin) {
|
|
267
|
+
invalid('working_repo_name', 'repository name and origin must be patched together');
|
|
268
|
+
}
|
|
269
|
+
if (hasName && hasOrigin) {
|
|
270
|
+
const name = patch.working_repo_name;
|
|
271
|
+
const origin = patch.working_repo_origin;
|
|
272
|
+
if ((name === null) !== (origin === null)) {
|
|
273
|
+
invalid('working_repo_name', 'repository name and origin must be set or cleared together');
|
|
274
|
+
}
|
|
275
|
+
if (name !== null && name !== undefined && origin !== null && origin !== undefined) {
|
|
276
|
+
const canonical = canonicalizeRepositoryIdentity(
|
|
277
|
+
origin,
|
|
278
|
+
name,
|
|
279
|
+
);
|
|
280
|
+
return { ...patch, ...canonical };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { ...patch };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface ValidatedRuntimeMetadataReportState {
|
|
287
|
+
runtime_metadata: DroneRuntimeMetadata;
|
|
288
|
+
runtime_metadata_reported: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Enforce the response invariant shared by attach, self-update, and identity reads. */
|
|
292
|
+
export function validateRuntimeMetadataReportState(
|
|
293
|
+
metadataValue: unknown,
|
|
294
|
+
reportedValue: unknown,
|
|
295
|
+
): ValidatedRuntimeMetadataReportState {
|
|
296
|
+
const runtime_metadata = validateRuntimeMetadata(metadataValue);
|
|
297
|
+
if (typeof reportedValue !== 'boolean') {
|
|
298
|
+
invalid('runtime_metadata_reported', 'must be a boolean');
|
|
299
|
+
}
|
|
300
|
+
if (!reportedValue && Object.values(runtime_metadata).some((value) => value !== null)) {
|
|
301
|
+
invalid('runtime_metadata_reported', 'false requires all metadata values to be null');
|
|
302
|
+
}
|
|
303
|
+
return { runtime_metadata, runtime_metadata_reported: reportedValue };
|
|
304
|
+
}
|