borgmcp-shared 0.5.1 → 0.6.2
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 +30 -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/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +5 -3
- package/dist/templates.js.map +1 -1
- package/docs/compatibility.md +28 -0
- package/docs/enrollment.md +13 -4
- package/docs/releasing.md +16 -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/templates.ts +5 -3
package/src/conformance/index.ts
CHANGED
|
@@ -216,7 +216,17 @@ const ATTACH_RESPONSE = {
|
|
|
216
216
|
result: 'created',
|
|
217
217
|
cube: { id: '10000000-0000-4000-8000-000000000001', name: 'test-cube' },
|
|
218
218
|
role: { id: '20000000-0000-4000-8000-000000000001', name: 'Coordinator' },
|
|
219
|
-
drone: {
|
|
219
|
+
drone: {
|
|
220
|
+
id: '30000000-0000-4000-8000-000000000001',
|
|
221
|
+
label: 'one-of-one-coordinator',
|
|
222
|
+
runtime_metadata: {
|
|
223
|
+
agent_kind: null,
|
|
224
|
+
reported_model: null,
|
|
225
|
+
working_repo_name: null,
|
|
226
|
+
working_repo_origin: null,
|
|
227
|
+
},
|
|
228
|
+
runtime_metadata_reported: false,
|
|
229
|
+
},
|
|
220
230
|
session: { id: '40000000-0000-4000-8000-000000000001' },
|
|
221
231
|
} satisfies AttachResponse;
|
|
222
232
|
|
|
@@ -237,3 +247,58 @@ export const ATTACH_SESSION_CONFORMANCE: readonly AttachSessionConformanceVector
|
|
|
237
247
|
accepts: false,
|
|
238
248
|
},
|
|
239
249
|
];
|
|
250
|
+
|
|
251
|
+
export interface RuntimeMetadataRepositoryConformanceVector {
|
|
252
|
+
name: string;
|
|
253
|
+
origin: string;
|
|
254
|
+
expected: { working_repo_name: string; working_repo_origin: string } | null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One canonical corpus consumed unchanged by shared, server, and client tests. */
|
|
258
|
+
export const RUNTIME_METADATA_REPOSITORY_CONFORMANCE:
|
|
259
|
+
readonly RuntimeMetadataRepositoryConformanceVector[] = [
|
|
260
|
+
{
|
|
261
|
+
name: 'canonical HTTPS',
|
|
262
|
+
origin: 'https://github.com/Byte-Ventures/borg-mcp',
|
|
263
|
+
expected: {
|
|
264
|
+
working_repo_name: 'Byte-Ventures/borg-mcp',
|
|
265
|
+
working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'HTTPS default port and suffix',
|
|
270
|
+
origin: 'https://GITHUB.com:443/Byte-Ventures/borg-mcp.git',
|
|
271
|
+
expected: {
|
|
272
|
+
working_repo_name: 'Byte-Ventures/borg-mcp',
|
|
273
|
+
working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: 'SSH URL',
|
|
278
|
+
origin: 'ssh://git@github.com:22/Byte-Ventures/borg-mcp.git',
|
|
279
|
+
expected: {
|
|
280
|
+
working_repo_name: 'Byte-Ventures/borg-mcp',
|
|
281
|
+
working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'SCP syntax',
|
|
286
|
+
origin: 'git@github.com:Byte-Ventures/borg-mcp.git',
|
|
287
|
+
expected: {
|
|
288
|
+
working_repo_name: 'Byte-Ventures/borg-mcp',
|
|
289
|
+
working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{ name: 'HTTPS userinfo', origin: 'https://user@github.com/owner/repo', expected: null },
|
|
293
|
+
{ name: 'SSH arbitrary user', origin: 'ssh://owner@github.com/owner/repo', expected: null },
|
|
294
|
+
{ name: 'query', origin: 'https://github.com/owner/repo?token=value', expected: null },
|
|
295
|
+
{ name: 'fragment', origin: 'https://github.com/owner/repo#fragment', expected: null },
|
|
296
|
+
{ name: 'percent encoding', origin: 'https://github.com/owner/re%70o', expected: null },
|
|
297
|
+
{ name: 'non-default port', origin: 'https://github.com:444/owner/repo', expected: null },
|
|
298
|
+
{ name: 'local file URL', origin: ['file:/', '', 'home', 'user', 'repo'].join('/'), expected: null },
|
|
299
|
+
{ name: 'relative path', origin: ['..', 'repo'].join('/'), expected: null },
|
|
300
|
+
{ name: 'loopback host', origin: 'https://127.0.0.1/owner/repo', expected: null },
|
|
301
|
+
{ name: 'IPv6 host', origin: 'https://[::1]/owner/repo', expected: null },
|
|
302
|
+
{ name: 'single-label host', origin: 'https://git/owner/repo', expected: null },
|
|
303
|
+
{ name: 'private suffix', origin: 'https://git.internal/owner/repo', expected: null },
|
|
304
|
+
];
|
package/src/index.ts
CHANGED
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.2' 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
|
+
}
|
package/src/templates.ts
CHANGED
|
@@ -222,9 +222,11 @@ Scope:
|
|
|
222
222
|
- Ask the human before rescoping, reprioritizing, abandoning, waiving a gate, creating an external issue or pull request, merging, deploying, publishing, tagging, or releasing unless that action was already explicitly delegated.
|
|
223
223
|
|
|
224
224
|
Activation:
|
|
225
|
-
-
|
|
226
|
-
- ACK
|
|
227
|
-
-
|
|
225
|
+
- Order named drones to start exact authorized work with START NOW, RESUME NOW, REVIEW NOW, or HOLD; name the exact item and first concrete action.
|
|
226
|
+
- ACK and claim are receipt only; neither means work has started or a review is complete.
|
|
227
|
+
- Unless HOLD, require STARTING or substantive PROGRESS within 2 minutes of routing. Directly kick a miss.
|
|
228
|
+
- After 5 more minutes without substantive response, probe liveness; reassign only when eligible and authorized.
|
|
229
|
+
- While work is active, require substantive PROGRESS at least every 10 minutes. Require immediate BLOCKED when safe work stops, naming the missing input while independent work continues.
|
|
228
230
|
- Waiting is valid when work is complete, blocked, under active review, or awaiting human authority. Never manufacture work to avoid idleness.
|
|
229
231
|
|
|
230
232
|
Review:
|