@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 +89 -0
- package/package.json +30 -0
- package/src/auth.ts +120 -0
- package/src/client.ts +903 -0
- package/src/connection-file.ts +109 -0
- package/src/envelope.ts +168 -0
- package/src/index.ts +92 -0
- package/src/provider.ts +1005 -0
- package/src/socket.ts +239 -0
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
|
+
}
|