@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.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @cortexkit/subc-client
2
+
3
+ TypeScript client for the [subc](../../README.md) daemon. It speaks the same
4
+ loopback-TCP transport as the Rust consumers (`subc-core`'s `subc-probe`),
5
+ wire-compatible **byte-for-byte** with `subc-transport` (the HMAC-SHA256
6
+ handshake) and `subc-protocol` (the 17-byte envelope and channel-0 control RPCs).
7
+
8
+ Use it when a TypeScript/JavaScript process needs to reach a subc-routed module
9
+ (e.g. a provider module exposing a tool surface or a management surface).
10
+
11
+ ## Install
12
+
13
+ It ships as source (no build step) and runs on Bun or Node ≥ 18 — the only
14
+ imports are `node:net`, `node:crypto`, and `node:fs`.
15
+
16
+ ```jsonc
17
+ // from the subconscious monorepo
18
+ "dependencies": { "@cortexkit/subc-client": "workspace:*" }
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ A consumer authenticates, optionally lists the catalog, opens a route to a
24
+ target module, then issues requests on the returned route channel. There is no
25
+ client `HELLO` — `HELLO` is module-registration only.
26
+
27
+ ```ts
28
+ import { SubcClient } from "@cortexkit/subc-client";
29
+
30
+ // The daemon publishes its connection file at $XDG_RUNTIME_DIR/subc-connection.json.
31
+ const client = await SubcClient.connect({ connectionFile });
32
+
33
+ // Optional: discover what is registered.
34
+ const modules = await client.catalogList();
35
+
36
+ // Open a route to a management-surface module and call it.
37
+ const routeChannel = await client.routeOpen(
38
+ { kind: "management_surface", module_id: "ai-provider-quota" },
39
+ { project_root: process.cwd(), harness: "my-harness", session: "session-1" },
40
+ );
41
+
42
+ // request() resolves to the module's full Response body (the parsed JSON),
43
+ // NOT an unwrapped field. A module decides its own response envelope; this one
44
+ // wraps its array under `result`, so read `body.result`.
45
+ const body = await client.request(routeChannel, { method: "usage.get", params: {} });
46
+ const usage = body.result; // ProviderUsage[] for the ai-provider-quota module
47
+
48
+ client.close();
49
+ ```
50
+
51
+ `connect()` runs the full handshake before resolving: `ClientHello` →
52
+ verify the server's proof **and** the daemon id from the connection file →
53
+ `ClientAuth`. A wrong key, an impostor daemon, or a tampered connection file
54
+ fails loud with an `AuthError` rather than connecting insecurely.
55
+
56
+ ### Routing notes
57
+
58
+ - **Correlation, not order.** Every request carries a correlation id; replies are
59
+ matched by `(channel, corr)`, never by arrival order. subc may interleave a
60
+ control reply ahead of another exchange's response on the same connection.
61
+ - **Priority.** Channel-0 control RPCs and data-plane requests are sent
62
+ `Interactive`. `request()` accepts `{ priority, timeoutMs, onProgress }`;
63
+ `onProgress` receives interim `Push`/`StreamData` frame bodies before the
64
+ terminal reply.
65
+ - **Errors.** A module that returns a `FrameType::Error` frame surfaces as a
66
+ thrown `SubcError` carrying the canonical `{ code, message }`.
67
+ - **Connection-file security.** On unix the file must be owner-only (`0600`); a
68
+ group/world-readable file is rejected, because the key has effectively leaked.
69
+
70
+ ## Testing
71
+
72
+ ```sh
73
+ bun test # 23 unit + 2 live-handshake
74
+ ```
75
+
76
+ The live-handshake tests boot the real `subc-core` binary
77
+ (`target/debug/subc-core`) and complete the handshake against it — the
78
+ byte-identity authority for this client. They skip automatically when the binary
79
+ is not built; run `cargo build -p subc-core` first (the CI lane does this).
80
+
81
+ ## Layout
82
+
83
+ | File | Responsibility |
84
+ | --- | --- |
85
+ | `src/envelope.ts` | 17-byte header codec, frame types, flags, priority |
86
+ | `src/connection-file.ts` | read + validate the daemon connection file (owner-only gate) |
87
+ | `src/socket.ts` | buffered TCP socket with deadline-bounded `readExact` |
88
+ | `src/auth.ts` | HMAC-SHA256 handshake (`computeProof`, constant-time verify) |
89
+ | `src/client.ts` | `SubcClient`: handshake, channel-0 RPCs, `request()` corr-mux |
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@cortexkit/subc-client",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript client for the subc daemon. Wire-compatible (byte-for-byte) with the Rust subc-transport handshake and subc-protocol envelope.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "bun test",
18
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "license": "MIT",
24
+ "devDependencies": {
25
+ "@types/bun": "^1.3.14",
26
+ "@types/node": "^22.0.0",
27
+ "bun-types": "^1.3.14",
28
+ "typescript": "^5.6.0"
29
+ }
30
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Port of subc-transport's auth.rs client handshake. The proof construction,
2
+ // domain strings, message framing, and verification order must match the Rust
3
+ // byte-for-byte: a single byte of drift fails authentication outright.
4
+ //
5
+ // Handshake (client side):
6
+ // 1. send ClientHello { client_nonce, role }
7
+ // 2. receive ServerProof { daemon_id, server_nonce, daemon_ver, server_proof }
8
+ // 3. verify server_proof == HMAC(key, "subc-server-v1" ‖ cn ‖ sn ‖ did) (constant-time)
9
+ // and daemon_id == the id from the connection file
10
+ // 4. send ClientAuth { client_auth = HMAC(key, "subc-client-v1" ‖ cn ‖ sn ‖ did) }
11
+ //
12
+ // Each message on the wire is a 4-byte little-endian length prefix followed by
13
+ // the JSON body. Byte arrays (nonces, proofs, ids) serialize as JSON arrays of
14
+ // numbers, matching serde's default for [u8; N].
15
+
16
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
17
+
18
+ import type { ConnectionInfo } from "./connection-file.js";
19
+ import { SubcSocket } from "./socket.js";
20
+
21
+ export const NONCE_LEN = 32;
22
+ export const PROOF_LEN = 32;
23
+ export const MAX_AUTH_MESSAGE_LEN = 4096;
24
+ export const SERVER_PROOF_DOMAIN = "subc-server-v1";
25
+ export const CLIENT_AUTH_DOMAIN = "subc-client-v1";
26
+ export const DEFAULT_CLIENT_ROLE = "client";
27
+
28
+ export class AuthError extends Error {}
29
+
30
+ /** HMAC-SHA256 over domain ‖ client_nonce ‖ server_nonce ‖ daemon_id. */
31
+ export function computeProof(
32
+ key: Uint8Array,
33
+ domain: string,
34
+ clientNonce: Uint8Array,
35
+ serverNonce: Uint8Array,
36
+ daemonId: Uint8Array,
37
+ ): Uint8Array {
38
+ const mac = createHmac("sha256", Buffer.from(key));
39
+ mac.update(Buffer.from(domain, "utf8"));
40
+ mac.update(Buffer.from(clientNonce));
41
+ mac.update(Buffer.from(serverNonce));
42
+ mac.update(Buffer.from(daemonId));
43
+ return new Uint8Array(mac.digest());
44
+ }
45
+
46
+ function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {
47
+ if (a.length !== b.length) return false;
48
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
49
+ }
50
+
51
+ async function writeMessage(
52
+ sock: SubcSocket,
53
+ value: unknown,
54
+ deadlineMs: number,
55
+ ): Promise<void> {
56
+ const json = Buffer.from(JSON.stringify(value), "utf8");
57
+ if (json.length > MAX_AUTH_MESSAGE_LEN) {
58
+ throw new AuthError(`auth message too large: ${json.length} > ${MAX_AUTH_MESSAGE_LEN}`);
59
+ }
60
+ const lenPrefix = new Uint8Array(4);
61
+ new DataView(lenPrefix.buffer).setUint32(0, json.length, true);
62
+ await sock.write(lenPrefix, deadlineMs);
63
+ await sock.write(json, deadlineMs);
64
+ }
65
+
66
+ async function readMessage<T>(sock: SubcSocket, deadlineMs: number): Promise<T> {
67
+ const lenBytes = await sock.readExact(4, deadlineMs);
68
+ const len = new DataView(lenBytes.buffer, lenBytes.byteOffset, 4).getUint32(0, true);
69
+ if (len > MAX_AUTH_MESSAGE_LEN) {
70
+ throw new AuthError(`auth message too large: ${len} > ${MAX_AUTH_MESSAGE_LEN}`);
71
+ }
72
+ const body = len === 0 ? new Uint8Array(0) : await sock.readExact(len, deadlineMs);
73
+ try {
74
+ return JSON.parse(Buffer.from(body).toString("utf8")) as T;
75
+ } catch (err) {
76
+ throw new AuthError(`auth message JSON decode failed: ${String(err)}`);
77
+ }
78
+ }
79
+
80
+ interface ServerProofMessage {
81
+ daemon_id: number[];
82
+ server_nonce: number[];
83
+ daemon_ver: string;
84
+ server_proof: number[];
85
+ }
86
+
87
+ /**
88
+ * Run the client handshake over an already-connected socket. Resolves on
89
+ * success; throws AuthError on any proof/identity mismatch or framing fault.
90
+ * The whole exchange is bounded by `deadlineMs` (epoch ms).
91
+ */
92
+ export async function authenticateClient(
93
+ sock: SubcSocket,
94
+ conn: ConnectionInfo,
95
+ deadlineMs: number,
96
+ ): Promise<void> {
97
+ const clientNonce = new Uint8Array(randomBytes(NONCE_LEN));
98
+
99
+ await writeMessage(
100
+ sock,
101
+ { client_nonce: Array.from(clientNonce), role: DEFAULT_CLIENT_ROLE },
102
+ deadlineMs,
103
+ );
104
+
105
+ const proof = await readMessage<ServerProofMessage>(sock, deadlineMs);
106
+ const serverNonce = Uint8Array.from(proof.server_nonce);
107
+ const daemonId = Uint8Array.from(proof.daemon_id);
108
+ const serverProof = Uint8Array.from(proof.server_proof);
109
+
110
+ const expected = computeProof(conn.key, SERVER_PROOF_DOMAIN, clientNonce, serverNonce, daemonId);
111
+ if (!constantTimeEq(expected, serverProof)) {
112
+ throw new AuthError("server proof mismatch — wrong key or impostor daemon");
113
+ }
114
+ if (!constantTimeEq(daemonId, conn.daemonId)) {
115
+ throw new AuthError("daemon id mismatch — connection file points at a different daemon");
116
+ }
117
+
118
+ const clientAuth = computeProof(conn.key, CLIENT_AUTH_DOMAIN, clientNonce, serverNonce, daemonId);
119
+ await writeMessage(sock, { client_auth: Array.from(clientAuth) }, deadlineMs);
120
+ }