@cortexkit/subc-client 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/subc-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "TypeScript client for the subc daemon. Wire-compatible (byte-for-byte) with the Rust subc-transport handshake and subc-protocol envelope.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -38,6 +38,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
38
38
  const BODY_READ_TIMEOUT_MS = 30_000;
39
39
  const EMPTY_BODY = new Uint8Array(0);
40
40
  const DEFAULT_MANAGED_TARGET_KIND: ManagedRouteKind = "management_surface";
41
+ export const SUBC_MODULE_ID_ENV = "SUBC_MODULE_ID";
42
+ export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
41
43
 
42
44
  export interface BindIdentity {
43
45
  project_root: string;
@@ -52,6 +54,16 @@ export type RouteTarget =
52
54
 
53
55
  export type ManagedRouteKind = "management_surface" | "tool_provider";
54
56
 
57
+ export interface ConsumerIdentity {
58
+ module_id: string;
59
+ launch_nonce: string;
60
+ }
61
+
62
+ export interface RouteOpenOptions {
63
+ /** Optional override for the consumer identity; by default the SUBC_MODULE_ID and SUBC_LAUNCH_NONCE environment variables are used when both are non-empty. Set null to send route.open without consumer_identity. */
64
+ consumerIdentity?: ConsumerIdentity | null;
65
+ }
66
+
55
67
  export interface CatalogEntry {
56
68
  module_id: string;
57
69
  roles: unknown[];
@@ -70,6 +82,8 @@ export interface ManagedCallOptions extends RequestOptions {
70
82
  identity?: BindIdentity;
71
83
  /** Defaults to management_surface, matching the store/host management APIs. */
72
84
  targetKind?: ManagedRouteKind;
85
+ /** Optional override for the consumer identity; by default the SUBC_MODULE_ID and SUBC_LAUNCH_NONCE environment variables are used when both are non-empty. Set null to send route.open without consumer_identity. */
86
+ consumerIdentity?: ConsumerIdentity | null;
73
87
  }
74
88
 
75
89
  export interface SubscribeOptions {
@@ -83,6 +97,8 @@ export interface CloseRouteOptions {
83
97
  * Defaults to false: close immediately, aborting everything in flight.
84
98
  */
85
99
  drain?: boolean;
100
+ /** Consumer identity to use when looking up the route to close; only needed for routes opened with a non-default consumer identity. */
101
+ consumerIdentity?: ConsumerIdentity | null;
86
102
  }
87
103
 
88
104
  /**
@@ -199,6 +215,7 @@ interface CachedRoute {
199
215
  moduleId: string;
200
216
  target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
201
217
  identity: BindIdentity;
218
+ consumerIdentity?: ConsumerIdentity;
202
219
  channel: number | null;
203
220
  generation: number;
204
221
  opening: Promise<number> | null;
@@ -252,8 +269,14 @@ export class SubcClient {
252
269
  }
253
270
 
254
271
  /** Open a route to a provider (channel-0 route.open); returns the route channel. */
255
- async routeOpen(target: RouteTarget, identity: BindIdentity): Promise<number> {
256
- const body = this.encode({ op: "route.open", target, identity });
272
+ async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<number> {
273
+ const consumerIdentity = routeOpenConsumerIdentity(opts);
274
+ const body = this.encode({
275
+ op: "route.open",
276
+ target,
277
+ identity,
278
+ ...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
279
+ });
257
280
  const reply = await this.controlRpc(body);
258
281
  const parsed = this.parseJson(reply) as { op: string; route_channel?: number };
259
282
  if (typeof parsed.route_channel !== "number") {
@@ -380,7 +403,7 @@ export class SubcClient {
380
403
  identity: BindIdentity,
381
404
  opts: CloseRouteOptions = {},
382
405
  ): Promise<void> {
383
- const key = routeCacheKey(target, identity);
406
+ const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
384
407
  const cached = this.routes.get(key);
385
408
  if (!cached) return; // never opened / already closed — idempotent no-op.
386
409
  // Generation guard: an in-flight openCachedRoute holds this same object and
@@ -498,7 +521,7 @@ export class SubcClient {
498
521
  timer: null,
499
522
  };
500
523
  pending.timer = setTimeout(() => {
501
- this.rejectPending(key, pending, new SubcError(`request on channel ${channel} timed out after ${ms}ms`));
524
+ this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms)));
502
525
  }, ms);
503
526
  this.pending.set(key, pending);
504
527
  this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
@@ -563,7 +586,7 @@ export class SubcClient {
563
586
  classifyFailure,
564
587
  };
565
588
  pending.timer = setTimeout(() => {
566
- this.rejectPending(key, pending, new SubcError(`request on channel ${channel} timed out after ${ms}ms`));
589
+ this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms)));
567
590
  }, ms);
568
591
  this.pending.set(key, pending);
569
592
 
@@ -590,7 +613,8 @@ export class SubcClient {
590
613
  RouteTarget,
591
614
  { kind: ManagedRouteKind }
592
615
  >;
593
- const key = routeCacheKey(target, identity);
616
+ const consumerIdentity = routeOpenConsumerIdentity(opts);
617
+ const key = routeCacheKey(target, identity, consumerIdentity);
594
618
  let cached = this.routes.get(key);
595
619
  if (!cached) {
596
620
  cached = {
@@ -598,6 +622,7 @@ export class SubcClient {
598
622
  moduleId,
599
623
  target,
600
624
  identity,
625
+ consumerIdentity,
601
626
  channel: null,
602
627
  generation: 0,
603
628
  opening: null,
@@ -630,7 +655,9 @@ export class SubcClient {
630
655
  }
631
656
 
632
657
  try {
633
- const channel = await this.routeOpen(cached.target, cached.identity);
658
+ const channel = await this.routeOpen(cached.target, cached.identity, {
659
+ consumerIdentity: cached.consumerIdentity ?? null,
660
+ });
634
661
  // Generation guard: a closeRoute may have flipped the tombstone WHILE this
635
662
  // route.open was in flight. If so, close wins — do NOT install the channel
636
663
  // into the (already-removed) cache entry; GOODBYE the channel we just opened
@@ -725,7 +752,14 @@ export class SubcClient {
725
752
  }
726
753
  for (const cached of this.routes.values()) {
727
754
  if (cached.closed) continue; // closed concurrently with reconnect — don't reopen.
728
- const channel = await this.routeOpen(cached.target, cached.identity);
755
+ // Thread the route's consumer identity through the reopen, exactly as the
756
+ // lazy per-call path (openCachedRoute) does. Dropping it here would make a
757
+ // route reopened after a reconnect send route.open with no consumer_identity,
758
+ // so the daemon would re-stamp it with a different (weaker) principal than the
759
+ // one it was originally bound under — a silent post-reconnect trust downgrade.
760
+ const channel = await this.routeOpen(cached.target, cached.identity, {
761
+ consumerIdentity: cached.consumerIdentity ?? null,
762
+ });
729
763
  // A closeRoute may have raced this reopen (flipping the tombstone during the
730
764
  // route.open await). If so, GOODBYE the channel instead of installing it, so the
731
765
  // closed route isn't silently re-established on the new connection.
@@ -738,6 +772,16 @@ export class SubcClient {
738
772
  }
739
773
  }
740
774
 
775
+ // A request timeout carries the local socket port and (channel, corr) so a
776
+ // packet capture can pinpoint the exact on-wire exchange — the decisive evidence
777
+ // for whether a "timed out" reply was actually delivered to this socket (a
778
+ // client-local demux problem) or never sent (a daemon/module problem).
779
+ private timeoutMessage(channel: number, corr: bigint, ms: number): string {
780
+ const port = this.sock.localPort();
781
+ const where = port === null ? "channel" : `local_port=${port} channel`;
782
+ return `request on ${where} ${channel} corr ${corr} timed out after ${ms}ms`;
783
+ }
784
+
741
785
  private routeClosedDuringOpen(): SubcCallError {
742
786
  return new SubcCallError("not_sent", "route was closed before route.open completed", "route_closed");
743
787
  }
@@ -885,8 +929,23 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
885
929
  };
886
930
  }
887
931
 
888
- function routeCacheKey(target: Extract<RouteTarget, { kind: ManagedRouteKind }>, identity: BindIdentity): string {
889
- return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}`;
932
+ function routeCacheKey(
933
+ target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
934
+ identity: BindIdentity,
935
+ consumerIdentity?: ConsumerIdentity,
936
+ ): string {
937
+ const consumerPart = consumerIdentity
938
+ ? `${consumerIdentity.module_id}\0${consumerIdentity.launch_nonce}`
939
+ : "";
940
+ return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}`;
941
+ }
942
+
943
+ function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentity | undefined {
944
+ if (opts.consumerIdentity !== undefined) return opts.consumerIdentity ?? undefined;
945
+ const moduleId = process.env[SUBC_MODULE_ID_ENV];
946
+ const launchNonce = process.env[SUBC_LAUNCH_NONCE_ENV];
947
+ if (!moduleId || !launchNonce) return undefined;
948
+ return { module_id: moduleId, launch_nonce: launchNonce };
890
949
  }
891
950
 
892
951
  function errorCode(err: unknown): string | undefined {
package/src/index.ts CHANGED
@@ -3,11 +3,15 @@ export {
3
3
  SubcError,
4
4
  SubcCallError,
5
5
  DEFAULT_RECONNECT_BACKOFF,
6
+ SUBC_MODULE_ID_ENV,
7
+ SUBC_LAUNCH_NONCE_ENV,
6
8
  isConsumerReconnectTransient,
7
9
  connectionFileExists,
8
10
  type BindIdentity,
9
11
  type RouteTarget,
10
12
  type ManagedRouteKind,
13
+ type ConsumerIdentity,
14
+ type RouteOpenOptions,
11
15
  type CatalogEntry,
12
16
  type RequestOptions,
13
17
  type ManagedCallOptions,
@@ -57,6 +61,7 @@ export {
57
61
  jsonProviderHandler,
58
62
  type ProviderRequestContext,
59
63
  type BindDecision,
64
+ type Principal,
60
65
  type BindingsInput,
61
66
  type CircuitBreakerInput,
62
67
  type Concurrency,
package/src/provider.ts CHANGED
@@ -202,10 +202,16 @@ export type ProviderHandler = (
202
202
  ctx: ProviderRequestContext,
203
203
  ) => Promise<Uint8Array | void> | Uint8Array | void;
204
204
 
205
+ export type Principal =
206
+ | { kind: "reserved"; module_id: string }
207
+ | { kind: "direct" }
208
+ | { kind: "unverified" };
209
+
205
210
  export interface RouteBindRequest {
206
211
  route_channel: number;
207
212
  target: RouteTarget;
208
213
  identity: BindIdentity;
214
+ principal?: Principal;
209
215
  }
210
216
 
211
217
  export type BindDecision =
@@ -533,6 +539,7 @@ export class SubcProvider {
533
539
  route_channel: numberField(request.route_channel, "route_channel"),
534
540
  target: request.target as RouteTarget,
535
541
  identity: request.identity as BindIdentity,
542
+ principal: request.principal as Principal | undefined,
536
543
  };
537
544
 
538
545
  const decision = await this.opts.onBind?.(bindRequest);
package/src/socket.ts CHANGED
@@ -64,6 +64,15 @@ export class SubcSocket {
64
64
  sock.on("close", () => fail(new SocketClosedError("subc connection closed")));
65
65
  }
66
66
 
67
+ /**
68
+ * The OS-assigned local TCP port of this connection, or null if not yet
69
+ * connected/closed. Used to correlate a client-side timeout with a specific
70
+ * socket in a packet capture when diagnosing reply-delivery issues.
71
+ */
72
+ localPort(): number | null {
73
+ return this.sock.localPort ?? null;
74
+ }
75
+
67
76
  static connect(host: string, port: number, deadlineMs: number): Promise<SubcSocket> {
68
77
  return new Promise((resolve, reject) => {
69
78
  const sock = net.connect({ host, port });