@cortexkit/subc-client 0.1.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.
@@ -0,0 +1,109 @@
1
+ // Port of subc-transport's connection_file.rs reader. The connection file is
2
+ // the daemon's published rendezvous record; its `key` is the shared transport
3
+ // secret. We refuse to trust a key from a file other local users can read,
4
+ // exactly as the Rust reader does, so a leaked (group/world-readable) key is a
5
+ // loud failure rather than a silent downgrade.
6
+
7
+ import { promises as fs } from "node:fs";
8
+
9
+ export const SCHEMA_VERSION = 1;
10
+ export const MIN_KEY_LEN = 32;
11
+ export const DAEMON_ID_LEN = 16;
12
+
13
+ export interface Endpoint {
14
+ host: string;
15
+ port: number;
16
+ }
17
+
18
+ export interface ConnectionInfo {
19
+ schema: number;
20
+ endpoints: Endpoint[];
21
+ /** Transport key bytes. Serialized on disk as a JSON array of numbers. */
22
+ key: Uint8Array;
23
+ /** 16-byte daemon identity. Serialized on disk as a JSON array of numbers. */
24
+ daemonId: Uint8Array;
25
+ pid: number;
26
+ daemonVer: string;
27
+ }
28
+
29
+ export class ConnectionFileError extends Error {}
30
+
31
+ function toBytes(value: unknown, field: string): Uint8Array {
32
+ if (!Array.isArray(value) || value.some((n) => typeof n !== "number")) {
33
+ throw new ConnectionFileError(`connection file field '${field}' must be a JSON array of bytes`);
34
+ }
35
+ return Uint8Array.from(value as number[]);
36
+ }
37
+
38
+ function validate(info: ConnectionInfo): void {
39
+ if (info.schema !== SCHEMA_VERSION) {
40
+ throw new ConnectionFileError(
41
+ `unsupported connection file schema ${info.schema}; expected ${SCHEMA_VERSION}`,
42
+ );
43
+ }
44
+ if (info.endpoints.length === 0) {
45
+ throw new ConnectionFileError("connection file must include at least one endpoint");
46
+ }
47
+ if (info.key.length < MIN_KEY_LEN) {
48
+ throw new ConnectionFileError(
49
+ `connection file key is too short: ${info.key.length} bytes, need at least ${MIN_KEY_LEN}`,
50
+ );
51
+ }
52
+ if (info.daemonId.length !== DAEMON_ID_LEN) {
53
+ throw new ConnectionFileError(
54
+ `connection file daemon_id must be ${DAEMON_ID_LEN} bytes, got ${info.daemonId.length}`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * On unix, reject any group/other permission bit: the key is published
61
+ * owner-only (0600), so a wider mode means the secret has leaked. On Windows the
62
+ * file inherits the per-user profile directory ACL at create time and there are
63
+ * no portable mode bits to re-check, matching the Rust no-op.
64
+ */
65
+ async function verifyOwnerOnly(path: string): Promise<void> {
66
+ if (process.platform === "win32") return;
67
+ const stat = await fs.stat(path);
68
+ const mode = stat.mode & 0o777;
69
+ if ((mode & 0o077) !== 0) {
70
+ throw new ConnectionFileError(
71
+ `connection file ${path} has insecure permissions 0o${mode.toString(8)}; expected owner-only 0600`,
72
+ );
73
+ }
74
+ }
75
+
76
+ /** Read, permission-check, and validate a connection file. */
77
+ export async function readConnectionFile(path: string): Promise<ConnectionInfo> {
78
+ await verifyOwnerOnly(path);
79
+ const raw = await fs.readFile(path, "utf8");
80
+ let parsed: Record<string, unknown>;
81
+ try {
82
+ parsed = JSON.parse(raw) as Record<string, unknown>;
83
+ } catch (err) {
84
+ throw new ConnectionFileError(`connection file JSON read failed for ${path}: ${String(err)}`);
85
+ }
86
+
87
+ const endpointsRaw = parsed.endpoints;
88
+ if (!Array.isArray(endpointsRaw)) {
89
+ throw new ConnectionFileError("connection file 'endpoints' must be an array");
90
+ }
91
+ const endpoints: Endpoint[] = endpointsRaw.map((e) => {
92
+ const ep = e as Record<string, unknown>;
93
+ if (typeof ep.host !== "string" || typeof ep.port !== "number") {
94
+ throw new ConnectionFileError("connection file endpoint must be { host: string, port: number }");
95
+ }
96
+ return { host: ep.host, port: ep.port };
97
+ });
98
+
99
+ const info: ConnectionInfo = {
100
+ schema: parsed.schema as number,
101
+ endpoints,
102
+ key: toBytes(parsed.key, "key"),
103
+ daemonId: toBytes(parsed.daemon_id, "daemon_id"),
104
+ pid: parsed.pid as number,
105
+ daemonVer: (parsed.daemon_ver as string) ?? "",
106
+ };
107
+ validate(info);
108
+ return info;
109
+ }
@@ -0,0 +1,168 @@
1
+ // Byte-for-byte port of subc-protocol's 17-byte envelope header.
2
+ // Source of truth: crates/subc-protocol/src/lib.rs. Keep field offsets, the
3
+ // little-endian encoding, and the frame-type/flag numbering in lock-step with
4
+ // the Rust; a one-byte drift here desynchronizes every frame on the wire.
5
+
6
+ export const PROTOCOL_VERSION = 1;
7
+ export const HEADER_LEN = 17;
8
+ export const FROZEN_PREFIX_LEN = 5;
9
+ export const MAX_FRAME_BODY_LEN = 64 * 1024 * 1024;
10
+
11
+ /** `type` byte at offset 5. */
12
+ export enum FrameType {
13
+ Request = 0,
14
+ Response = 1,
15
+ Push = 2,
16
+ StreamData = 3,
17
+ StreamEnd = 4,
18
+ Error = 5,
19
+ Cancel = 6,
20
+ Ping = 7,
21
+ Pong = 8,
22
+ Hello = 9,
23
+ HelloAck = 10,
24
+ Goodbye = 11,
25
+ }
26
+
27
+ const FRAME_TYPE_MAX = FrameType.Goodbye;
28
+
29
+ /** Cancel/Ping/Pong/Goodbye carry only a header (`len` must be 0). */
30
+ export function isPureHeader(ty: FrameType): boolean {
31
+ return (
32
+ ty === FrameType.Cancel ||
33
+ ty === FrameType.Ping ||
34
+ ty === FrameType.Pong ||
35
+ ty === FrameType.Goodbye
36
+ );
37
+ }
38
+
39
+ /** Scheduling priority carried in flags bits 1-2. */
40
+ export enum Priority {
41
+ Passive = 0,
42
+ Interactive = 1,
43
+ Background = 2,
44
+ }
45
+
46
+ const FLAG_BINARY = 0b0000_0001; // bit 0
47
+ const FLAG_PRIORITY_MASK = 0b0000_0110; // bits 1-2
48
+ const FLAG_PRIORITY_SHIFT = 1;
49
+ const FLAG_LAST = 0b0000_1000; // bit 3
50
+ const FLAG_RESERVED_MASK = 0b1111_0000; // bits 4-7 must be zero
51
+
52
+ /** Build the flags byte from typed components (mirrors Flags::new). */
53
+ export function buildFlags(binary: boolean, priority: Priority, last: boolean): number {
54
+ let b = 0;
55
+ if (binary) b |= FLAG_BINARY;
56
+ b |= priority << FLAG_PRIORITY_SHIFT;
57
+ if (last) b |= FLAG_LAST;
58
+ return b;
59
+ }
60
+
61
+ export interface EnvelopeHeader {
62
+ len: number;
63
+ ver: number;
64
+ ty: FrameType;
65
+ flags: number;
66
+ channel: number;
67
+ corr: bigint;
68
+ }
69
+
70
+ export interface Frame {
71
+ header: EnvelopeHeader;
72
+ body: Uint8Array;
73
+ }
74
+
75
+ /** Serialize a header to its fixed 17-byte little-endian form. */
76
+ export function encodeHeader(h: EnvelopeHeader): Uint8Array {
77
+ const buf = new Uint8Array(HEADER_LEN);
78
+ const view = new DataView(buf.buffer);
79
+ view.setUint32(0, h.len, true);
80
+ buf[4] = h.ver;
81
+ buf[5] = h.ty;
82
+ buf[6] = h.flags;
83
+ view.setUint16(7, h.channel, true);
84
+ view.setBigUint64(9, h.corr, true);
85
+ return buf;
86
+ }
87
+
88
+ export class DecodeError extends Error {}
89
+
90
+ /**
91
+ * Decode a header from the front of `bytes`, following the frozen-prefix
92
+ * discipline: need 5 bytes for len+ver, dispatch full header length on ver,
93
+ * then validate. Mirrors decode_header — never throws on a structurally short
94
+ * buffer beyond the typed DecodeError.
95
+ */
96
+ export function decodeHeader(bytes: Uint8Array): EnvelopeHeader {
97
+ if (bytes.length < FROZEN_PREFIX_LEN) {
98
+ throw new DecodeError(`header shorter than frozen prefix: have ${bytes.length} bytes`);
99
+ }
100
+ const ver = bytes[4]!;
101
+ if (ver !== PROTOCOL_VERSION) {
102
+ throw new DecodeError(`unsupported envelope version ${ver}`);
103
+ }
104
+ if (bytes.length < HEADER_LEN) {
105
+ throw new DecodeError(`header too short for version: have ${bytes.length} bytes, need ${HEADER_LEN}`);
106
+ }
107
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
108
+ const len = view.getUint32(0, true);
109
+ const tyByte = bytes[5]!;
110
+ if (tyByte > FRAME_TYPE_MAX) {
111
+ throw new DecodeError(`unknown frame type byte ${tyByte}`);
112
+ }
113
+ const ty = tyByte as FrameType;
114
+ const flags = bytes[6]!;
115
+ if ((flags & FLAG_RESERVED_MASK) !== 0) {
116
+ throw new DecodeError(`reserved flag bits set in flags 0b${flags.toString(2)}`);
117
+ }
118
+ if (((flags & FLAG_PRIORITY_MASK) >> FLAG_PRIORITY_SHIFT) === 0b11) {
119
+ throw new DecodeError(`reserved priority bits set in flags 0b${flags.toString(2)}`);
120
+ }
121
+ if (isPureHeader(ty) && len !== 0) {
122
+ throw new DecodeError(`pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`);
123
+ }
124
+ const channel = view.getUint16(7, true);
125
+ const corr = view.getBigUint64(9, true);
126
+ return { len, ver, ty, flags, channel, corr };
127
+ }
128
+
129
+ /** Build a full current-version frame, enforcing the body-length cap and the pure-header rule. */
130
+ export function buildFrame(
131
+ ty: FrameType,
132
+ flags: number,
133
+ channel: number,
134
+ corr: bigint,
135
+ body: Uint8Array,
136
+ ): Frame {
137
+ return buildFrameWithVersion(PROTOCOL_VERSION, ty, flags, channel, corr, body);
138
+ }
139
+
140
+ /** Build a full frame while preserving a peer-negotiated envelope version. */
141
+ export function buildFrameWithVersion(
142
+ ver: number,
143
+ ty: FrameType,
144
+ flags: number,
145
+ channel: number,
146
+ corr: bigint,
147
+ body: Uint8Array,
148
+ ): Frame {
149
+ if (body.length > MAX_FRAME_BODY_LEN) {
150
+ throw new DecodeError(`frame body ${body.length} exceeds max ${MAX_FRAME_BODY_LEN}`);
151
+ }
152
+ if (isPureHeader(ty) && body.length !== 0) {
153
+ throw new DecodeError(`pure-header frame ${FrameType[ty]} cannot carry a body`);
154
+ }
155
+ return {
156
+ header: { len: body.length, ver, ty, flags, channel, corr },
157
+ body,
158
+ };
159
+ }
160
+
161
+ /** Encode a frame to wire bytes: 17-byte header followed by `len` body bytes. */
162
+ export function encodeFrame(frame: Frame): Uint8Array {
163
+ const header = encodeHeader(frame.header);
164
+ const out = new Uint8Array(header.length + frame.body.length);
165
+ out.set(header, 0);
166
+ out.set(frame.body, header.length);
167
+ return out;
168
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ export {
2
+ SubcClient,
3
+ SubcError,
4
+ SubcCallError,
5
+ DEFAULT_RECONNECT_BACKOFF,
6
+ isConsumerReconnectTransient,
7
+ connectionFileExists,
8
+ type BindIdentity,
9
+ type RouteTarget,
10
+ type ManagedRouteKind,
11
+ type CatalogEntry,
12
+ type RequestOptions,
13
+ type ManagedCallOptions,
14
+ type ConnectOptions,
15
+ type ReconnectBackoff,
16
+ type SubcCallErrorKind,
17
+ type SubscribeOptions,
18
+ type Subscription,
19
+ type CloseRouteOptions,
20
+ } from "./client.js";
21
+ export {
22
+ readConnectionFile,
23
+ ConnectionFileError,
24
+ type ConnectionInfo,
25
+ type Endpoint,
26
+ } from "./connection-file.js";
27
+ export {
28
+ FrameType,
29
+ Priority,
30
+ PROTOCOL_VERSION,
31
+ HEADER_LEN,
32
+ buildFrame,
33
+ buildFrameWithVersion,
34
+ buildFlags,
35
+ encodeFrame,
36
+ decodeHeader,
37
+ encodeHeader,
38
+ DecodeError,
39
+ type Frame,
40
+ type EnvelopeHeader,
41
+ } from "./envelope.js";
42
+ export {
43
+ authenticateClient,
44
+ computeProof,
45
+ AuthError,
46
+ NONCE_LEN,
47
+ PROOF_LEN,
48
+ SERVER_PROOF_DOMAIN,
49
+ CLIENT_AUTH_DOMAIN,
50
+ } from "./auth.js";
51
+ export { SubcSocket, SocketClosedError, SocketTimeoutError } from "./socket.js";
52
+ export {
53
+ SubcProvider,
54
+ SubcProviderError,
55
+ HELLO_CORR,
56
+ managementSurfaceManifest,
57
+ jsonProviderHandler,
58
+ type ProviderRequestContext,
59
+ type BindDecision,
60
+ type BindingsInput,
61
+ type CircuitBreakerInput,
62
+ type Concurrency,
63
+ type ConsumerRoleInput,
64
+ type ExecutionMode,
65
+ type IdentityBindingInput,
66
+ type IdentityScope,
67
+ type InternalTransport,
68
+ type LeaseScope,
69
+ type ManagementOperationInput,
70
+ type ManagementOperationKind,
71
+ type ManagementSurfaceManifestOptions,
72
+ type ManifestInput,
73
+ type ModelPolicyInput,
74
+ type ModuleHelloAckBody,
75
+ type ObservabilityKind,
76
+ type ObservabilitySurfaceInput,
77
+ type PipelineAppliesToInput,
78
+ type PipelineStageKind,
79
+ type ProviderConnectionState,
80
+ type ProviderHandler,
81
+ type ProviderRoleInput,
82
+ type RouteBindRequest,
83
+ type ScheduledTaskInput,
84
+ type StorageBindingInput,
85
+ type StorageKind,
86
+ type StorageScope,
87
+ type SubcProviderConnectOptions,
88
+ type TaskEligibilityInput,
89
+ type ToolInput,
90
+ type TrustTier,
91
+ type VaultGrantInput,
92
+ } from "./provider.js";